From b51c78a0c50ff85dc281adfa76599d524782588f Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:42:45 -0600 Subject: [PATCH] feat(scenarios): add scenic world tour with mid-orbit map mode transitions and immersive recording UX - Introduce scenic_world_tour with 6 photorealistic destinations: Copacabana (Rio), Mt Fuji, Grand Canyon, Golden Gate Bridge, The Shard (London), and Seattle Space Needle. - Support seamless mid-orbit map mode switching (switchMapMode in FlyAroundStep) revealing hybrid labels and 3D building POIs dynamically mid-orbit. - Surface direct tour shortcuts on MainActivity launcher for instant access. - Implement sticky immersive mode in ScenariosActivity to hide system status and navigation bars during scenario playback for clean screen recordings. - Remove skip button overlay during tour playback for unobscured recording view. - Fix coroutine await and state flow exposure in Map3dViewModel. --- .../advanced/app/src/main/AndroidManifest.xml | 9 +- .../advancedmaps3dsamples/MainActivity.kt | 25 +++- .../common/Map3dViewModel.kt | 6 +- .../scenarios/Animations.kt | 31 ++++- .../scenarios/Scenario.kt | 3 + .../scenarios/ScenarioData.kt | 122 ++++++++++++++++++ .../scenarios/ScenarioMapper.kt | 14 +- .../scenarios/ScenariosActivity.kt | 60 +++++++-- .../scenarios/ScenariosViewModel.kt | 61 +++++++-- .../scenarios/ThreeDMap.kt | 4 +- .../app/src/main/res/values/strings.xml | 4 + 11 files changed, 302 insertions(+), 37 deletions(-) diff --git a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml index c573539a..11cffce5 100644 --- a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml @@ -49,7 +49,14 @@ + android:launchMode="singleTop"> + + + + + + + ) +data class MapSample( + @StringRes val label: Int, + val clazz: Class<*>, + val scenario: String? = null, +) private val samples = listOf( - MapSample(R.string.map_sample_basic, BasicComposeMapActivity::class.java), + MapSample(R.string.scenarios_seattle_cinematic_reveal, ScenariosActivity::class.java, "seattle_cinematic_reveal"), + MapSample(R.string.scenarios_uk_shard_tower_bridge, ScenariosActivity::class.java, "uk_shard_to_tower_bridge"), + MapSample(R.string.scenarios_brazil_copacabana_ipanema, ScenariosActivity::class.java, "brazil_copacabana_to_ipanema"), + MapSample(R.string.scenarios_scenic_world_tour, ScenariosActivity::class.java, "scenic_world_tour"), MapSample(R.string.map_sample_scenarios, ScenariosActivity::class.java), + MapSample(R.string.map_sample_basic, BasicComposeMapActivity::class.java), MapSample(R.string.map_sample_route, RouteSampleActivity::class.java), ) @@ -83,7 +91,7 @@ class MainActivity : ComponentActivity() { for (sample in samples) { Sample( sample = sample, - onClick = { launchActivity(sample.clazz) }, + onClick = { launchActivity(sample) }, modifier = Modifier.fillMaxWidth(), ) } @@ -94,12 +102,15 @@ class MainActivity : ComponentActivity() { } - private fun launchActivity(clazz: Class<*>) { + private fun launchActivity(sample: MapSample) { try { - startActivity(Intent(this, clazz)) + val intent = Intent(this, sample.clazz).apply { + sample.scenario?.let { putExtra("scenario", it) } + } + startActivity(intent) } catch (e: ClassNotFoundException) { - Log.e("SamplesMenu", "Activity not found: $clazz", e) - Toast.makeText(this, "Activity not found: $clazz", Toast.LENGTH_SHORT).show() + Log.e("SamplesMenu", "Activity not found: ${sample.clazz}", e) + Toast.makeText(this, "Activity not found: ${sample.clazz}", Toast.LENGTH_SHORT).show() } } } 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 2b872293..a4ee0e46 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 @@ -68,6 +68,7 @@ abstract class Map3dViewModel : ViewModel() { * Consumers should use the `mapReady` flow to react to the availability of the map. */ private var _googleMap3D = MutableStateFlow(null) + val googleMap3D = _googleMap3D.asStateFlow() private val _cameraRestriction = MutableStateFlow(null) val cameraRestriction = _cameraRestriction.asStateFlow() @@ -402,8 +403,7 @@ abstract class Map3dViewModel : ViewModel() { } suspend fun awaitCameraUpdate(cameraUpdate: CameraUpdate) { - _googleMap3D.value?.let { controller -> - com.example.advancedmaps3dsamples.utils.awaitCameraUpdate(controller, cameraUpdate) - } + val controller = _googleMap3D.filterNotNull().first() + com.example.advancedmaps3dsamples.utils.awaitCameraUpdate(controller, cameraUpdate) } } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt index f1b97e52..08c8a763 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt @@ -16,7 +16,9 @@ package com.example.advancedmaps3dsamples.scenarios import com.google.android.gms.maps3d.model.FlyAroundOptions import com.google.android.gms.maps3d.model.FlyToOptions +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch sealed interface AnimationStep { suspend operator fun invoke(viewModel: ScenariosViewModel) @@ -46,8 +48,33 @@ data class FlyToStep(val flyToOptions: FlyToOptions) : AnimationStep { } } -data class FlyAroundStep(val flyAroundOptions: FlyAroundOptions) : AnimationStep { +data class FlyAroundStep( + val flyAroundOptions: FlyAroundOptions, + val switchMapMode: Int? = null, + val switchDelayMillis: Long = 0L, +) : AnimationStep { override suspend operator fun invoke(viewModel: ScenariosViewModel) { - viewModel.awaitFlyAround(flyAroundOptions) + if (switchMapMode != null && switchDelayMillis > 0) { + coroutineScope { + val switchJob = launch { + delay(switchDelayMillis) + android.util.Log.i("FlyAroundStep", "Switching map mode to $switchMapMode after ${switchDelayMillis}ms") + viewModel.setMapMode(switchMapMode) + } + try { + viewModel.awaitFlyAround(flyAroundOptions) + } finally { + switchJob.cancel() + } + } + } else { + viewModel.awaitFlyAround(flyAroundOptions) + } + } +} + +data class SetMapModeStep(val mapMode: Int) : AnimationStep { + override suspend operator fun invoke(viewModel: ScenariosViewModel) { + viewModel.setMapMode(mapMode) } } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Scenario.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Scenario.kt index f2390032..8dbd2b3b 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Scenario.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Scenario.kt @@ -34,6 +34,7 @@ data class Scenario( val models: List = emptyList(), // Add models property val polylines: List = emptyList(), val polygons: List = emptyList(), + val loop: Boolean = false, ) { @Composable fun getTitle() = stringResource(titleId) @@ -70,6 +71,7 @@ fun createScenario( models: String = "", polylines: String = "", polygon: String? = null, + loop: Boolean = false, ): Scenario { return Scenario( name = name, @@ -80,6 +82,7 @@ fun createScenario( models = models.toModels(), polylines = polylines.toPolylines(), polygons = polygon?.toPolygons() ?: emptyList(), + loop = loop, ) } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt index 8da75307..5ab1fcc4 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt @@ -183,6 +183,128 @@ val denverPolygon = """ val scenarios = listOf( + createScenario( + name = "seattle_cinematic_reveal", + titleId = R.string.scenarios_seattle_cinematic_reveal, + initialState = + "mode=roadmap;camera=lat=47.6100,lng=-122.3950,alt=1200.0,hdg=55.0,tilt=60.0,range=7500.0", + animationString = + "waitUntilTheMapIsSteady=timeout=10000;" + + // Stop 1: Space Needle, Seattle (Fly-in in ROADMAP, orbit, and reveal HYBRID mid-orbit) + "setMapMode=roadmap;" + + "flyTo=lat=47.620506,lng=-122.349277,alt=120.0,hdg=135.0,tilt=67.0,range=650.0,dur=4500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=47.620506,lng=-122.349277,alt=120.0,hdg=135.0,tilt=67.0,range=650.0,dur=20000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1500;" + + // Stop 2: Downtown Seattle & Columbia Center (Glide to downtown high-rises in HYBRID) + "flyTo=lat=47.604500,lng=-122.330700,alt=180.0,hdg=315.0,tilt=68.0,range=750.0,dur=4000;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=47.604500,lng=-122.330700,alt=180.0,hdg=315.0,tilt=68.0,range=750.0,dur=18000,count=1.0;" + + "delay=dur=1500", + loop = true, + ), + createScenario( + name = "uk_shard_to_tower_bridge", + titleId = R.string.scenarios_uk_shard_tower_bridge, + initialState = + "mode=satellite;camera=lat=51.504500,lng=-0.086500,alt=140.0,hdg=35.0,tilt=55.0,range=1200.0", + animationString = + "waitUntilTheMapIsSteady=timeout=10000;" + + // Stop 1: The Shard, London + "setMapMode=satellite;" + + "flyTo=lat=51.504500,lng=-0.086500,alt=140.0,hdg=35.0,tilt=68.0,range=650.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=51.504500,lng=-0.086500,alt=140.0,hdg=35.0,tilt=68.0,range=650.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // Stop 2: Tower Bridge, London + "setMapMode=satellite;" + + "flyTo=lat=51.505500,lng=-0.075400,alt=20.0,hdg=-16.4,tilt=65.0,range=564.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=51.505500,lng=-0.075400,alt=20.0,hdg=-16.4,tilt=65.0,range=564.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + "setMapMode=satellite", + loop = true, + ), + createScenario( + name = "brazil_copacabana_to_ipanema", + titleId = R.string.scenarios_brazil_copacabana_ipanema, + initialState = + "mode=satellite;camera=lat=-22.971100,lng=-43.182500,alt=25.0,hdg=38.0,tilt=55.0,range=2200.0", + animationString = + "waitUntilTheMapIsSteady=timeout=10000;" + + // Stop 1: Copacabana Beach, Rio de Janeiro + "setMapMode=satellite;" + + "flyTo=lat=-22.971100,lng=-43.182500,alt=25.0,hdg=38.0,tilt=68.0,range=1350.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=-22.971100,lng=-43.182500,alt=25.0,hdg=38.0,tilt=68.0,range=1350.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // Stop 2: Ipanema Beach & Arpoador, Rio de Janeiro + "setMapMode=satellite;" + + "flyTo=lat=-22.988800,lng=-43.192600,alt=25.0,hdg=282.0,tilt=67.0,range=1150.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=-22.988800,lng=-43.192600,alt=25.0,hdg=282.0,tilt=67.0,range=1150.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + "setMapMode=satellite", + loop = true, + ), + createScenario( + name = "scenic_world_tour", + titleId = R.string.scenarios_scenic_world_tour, + initialState = + "mode=satellite;camera=lat=51.504500,lng=-0.086500,alt=140.0,hdg=35.0,tilt=55.0,range=1200.0", + animationString = + "waitUntilTheMapIsSteady=timeout=10000;" + + // 1. UK: The Shard, London + "setMapMode=satellite;" + + "flyTo=lat=51.504500,lng=-0.086500,alt=140.0,hdg=35.0,tilt=68.0,range=650.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=51.504500,lng=-0.086500,alt=140.0,hdg=35.0,tilt=68.0,range=650.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 2. UK: Tower Bridge, London + "setMapMode=satellite;" + + "flyTo=lat=51.505500,lng=-0.075400,alt=20.0,hdg=-16.4,tilt=65.0,range=564.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=51.505500,lng=-0.075400,alt=20.0,hdg=-16.4,tilt=65.0,range=564.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 3. Brazil: Copacabana Beach, Rio de Janeiro + "setMapMode=satellite;" + + "flyTo=lat=-22.971100,lng=-43.182500,alt=25.0,hdg=38.0,tilt=68.0,range=1350.0,dur=4500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=-22.971100,lng=-43.182500,alt=25.0,hdg=38.0,tilt=68.0,range=1350.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 4. Brazil: Ipanema Beach & Arpoador, Rio de Janeiro + "setMapMode=satellite;" + + "flyTo=lat=-22.988800,lng=-43.192600,alt=25.0,hdg=282.0,tilt=67.0,range=1150.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=-22.988800,lng=-43.192600,alt=25.0,hdg=282.0,tilt=67.0,range=1150.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 5. USA: Golden Gate Bridge & Marin Headlands (wide framing to capture full span and bay) + "setMapMode=satellite;" + + "flyTo=lat=37.819900,lng=-122.478300,alt=180.0,hdg=106.0,tilt=62.0,range=4000.0,dur=4500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=37.819900,lng=-122.478300,alt=180.0,hdg=106.0,tilt=62.0,range=4000.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 6. USA: Grand Canyon South Rim (panoramic vista of canyon gorges and red strata) + "setMapMode=satellite;" + + "flyTo=lat=36.057000,lng=-112.143000,alt=2200.0,hdg=35.0,tilt=68.0,range=14000.0,dur=4000;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=36.057000,lng=-112.143000,alt=2200.0,hdg=35.0,tilt=68.0,range=14000.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 7. USA: Delicate Arch, Arches National Park + "setMapMode=satellite;" + + "flyTo=lat=38.743502,lng=-109.499374,alt=1467.0,hdg=-10.4,tilt=58.1,range=180.0,dur=3500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=38.743502,lng=-109.499374,alt=1467.0,hdg=-10.4,tilt=58.1,range=180.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + // 8. Japan: Mount Fuji Summit Crater + "setMapMode=satellite;" + + "flyTo=lat=35.360600,lng=138.727400,alt=3650.0,hdg=145.0,tilt=64.0,range=4200.0,dur=4500;" + + "waitUntilTheMapIsSteady=timeout=10000;" + + "flyAround=lat=35.360600,lng=138.727400,alt=3650.0,hdg=145.0,tilt=64.0,range=4200.0,dur=12000,count=1.0,switchmode=hybrid,switchdelay=6000;" + + "delay=dur=1000;" + + "setMapMode=satellite", + loop = true, + ), createScenario( name = "tower_bridge", titleId = R.string.scenarios_tower_bridge, diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt index 03892003..acdff54c 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt @@ -152,6 +152,7 @@ fun String.toMap3DMode(): Int { return when (this.lowercase()) { "hybrid" -> Map3DMode.HYBRID "satellite" -> Map3DMode.SATELLITE + "roadmap" -> Map3DMode.ROADMAP else -> { Log.w(TAG, "Unsupported map mode '$this', defaulting to SATELLITE.") Map3DMode.SATELLITE @@ -172,7 +173,18 @@ fun String.toAnimation(): List { when (key.trim().lowercase()) { "flyto" -> add(FlyToStep(value.toFlyTo())) "delay" -> add(DelayStep(value.toDelay())) - "flyaround" -> add(FlyAroundStep(value.toFlyAround())) + "flyaround" -> { + val options = value.toFlyAround() + val attributes = value.toAttributesMap() + val switchModeStr = attributes["switchmode"] ?: attributes["switchMode"] + val switchMode = switchModeStr?.toMap3DMode() + val switchDelay = (attributes["switchdelay"] ?: attributes["switchDelay"])?.toLongOrNull() ?: 0L + Log.d(TAG, "Parsed flyAround: switchMode=$switchMode ($switchModeStr), switchDelay=$switchDelay") + add(FlyAroundStep(options, switchMapMode = switchMode, switchDelayMillis = switchDelay)) + } + "setmapmode", "mapmode" -> { + add(SetMapModeStep(value.trim().toMap3DMode())) + } "waituntilthemapissteady" -> { add(WaitUntilTheMapIsSteadyStep(value.toTimeout())) } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt index d7f87b90..8aac6412 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt @@ -33,7 +33,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.CameraAlt @@ -44,11 +46,13 @@ import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -60,6 +64,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.advancedmaps3dsamples.R import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme @@ -79,14 +86,14 @@ class ScenariosActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - intent.extras?.getString("scenario")?.let { scenarioName: String -> - viewModel.setScenario(scenarioName) - } + setIntent(intent) + handleScenarioIntent(intent) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + handleScenarioIntent(intent) setContent { val viewState by viewModel.viewState.collectAsStateWithLifecycle() val currentCamera by viewModel.currentCamera.collectAsStateWithLifecycle(Camera.DEFAULT_CAMERA) @@ -96,24 +103,40 @@ class ScenariosActivity : ComponentActivity() { val cameraAttribute by viewModel.trackedAttribute.collectAsStateWithLifecycle() + DisposableEffect(viewState.scenario) { + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + if (viewState.scenario != null) { + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + } else { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + onDispose { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + } + AdvancedMaps3DSamplesTheme( dynamicColor = false ) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - CenterAlignedTopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(stringResource(viewState.scenario?.titleId ?: R.string.scenarios_none)) - } - ) + if (viewState.scenario == null) { + CenterAlignedTopAppBar( + colors = topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Text(stringResource(R.string.scenarios_none)) + }, + ) + } } ) { innerPadding -> - val modifier = Modifier.padding(innerPadding) + val modifier = if (viewState.scenario == null) Modifier.padding(innerPadding) else Modifier viewState.scenario?.let { scenario -> Box(modifier = modifier.fillMaxSize()) { @@ -164,6 +187,16 @@ class ScenariosActivity : ComponentActivity() { } } } + + private fun handleScenarioIntent(intent: Intent?) { + val scenarioName = + intent?.extras?.getString("scenario") + ?: intent?.data?.lastPathSegment + ?: intent?.data?.host + if (!scenarioName.isNullOrBlank()) { + viewModel.setScenario(scenarioName) + } + } } /** @@ -182,6 +215,7 @@ fun ScenarioPicker(modifier: Modifier, onScenarioClicked: (Scenario) -> Unit) { modifier = modifier .testTag("scenario_picker") .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt index 63e6f499..8214239b 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt @@ -80,6 +80,8 @@ class ScenariosViewModel @Inject constructor() : Map3dViewModel() { private val _roll = MutableStateFlow(DEFAULT_ROLL) val roll = _roll as StateFlow + private var currentStepIndex: Int = 0 + init { viewModelScope.launch { viewState @@ -186,8 +188,33 @@ class ScenariosViewModel @Inject constructor() : Map3dViewModel() { setScenario(nextScenario) // Set the next scenario } + /** + * Advances to the next FlyTo stop within the current scenario, wrapping around to the first + * FlyTo stop if at the end. If the scenario has fewer than 2 FlyTo stops, advances to the next + * scenario. + */ + fun nextStop() { + val scenario = _viewState.value.scenario ?: return + val steps = scenario.animationSteps + val flyToIndices = steps.indices.filter { steps[it] is FlyToStep } + if (flyToIndices.size < 2) { + nextScenario() + return + } + val nextFlyToIndex = + flyToIndices.firstOrNull { it > currentStepIndex } ?: flyToIndices.first() + val actualStartIndex = + if (nextFlyToIndex > 0 && steps[nextFlyToIndex - 1] is SetMapModeStep) { + nextFlyToIndex - 1 + } else { + nextFlyToIndex + } + Log.i(TAG, "nextStop called, currentStepIndex=$currentStepIndex, jumping to $actualStartIndex") + playAnimation(steps, startStepIndex = actualStartIndex, showCountDown = false) + } + private fun isMapReady(): Boolean { - return _viewState.value.scenario != null + return _viewState.value.scenario != null && googleMap3D.value != null } /** @@ -195,19 +222,27 @@ class ScenariosViewModel @Inject constructor() : Map3dViewModel() { * * The function first cancels any ongoing animation jobs. * It then starts a countdown displayed to the user before executing the animation steps. - * The animation steps are executed sequentially. - * After the animation is finished, a delay is introduced before the "finished" state is shown. + * The animation steps are executed sequentially, looping back to the first [FlyToStep] if + * the current scenario has `loop = true`. * * @param animation The list of [AnimationStep] to play. + * @param startStepIndex The step index to start playback from. + * @param showCountDown Whether to display the 3-second countdown overlay before starting. */ - private fun playAnimation(animation: List) { + private fun playAnimation( + animation: List, + startStepIndex: Int = 0, + showCountDown: Boolean = true, + ) { animationJob?.cancel() animationJob = viewModelScope.launch { - for (count in 3 downTo 1) { - _viewState.value = viewState.value.copy(countDown = count, countDownVisible = true) - delay(1.seconds) + if (showCountDown) { + for (count in 3 downTo 1) { + _viewState.value = viewState.value.copy(countDown = count, countDownVisible = true) + delay(1.seconds) + } } while (!isMapReady()) { @@ -215,7 +250,17 @@ class ScenariosViewModel @Inject constructor() : Map3dViewModel() { } _viewState.value = viewState.value.copy(countDown = 0, countDownVisible = false) - animation.forEach { step -> step(this@ScenariosViewModel) } + var index = startStepIndex + while (index < animation.size) { + currentStepIndex = index + Log.i(TAG, "Playing step $index: ${animation[index]}") + animation[index](this@ScenariosViewModel) + index++ + if (index >= animation.size && _viewState.value.scenario?.loop == true) { + val loopStartIndex = animation.indexOfFirst { it is FlyToStep || it is SetMapModeStep } + index = if (loopStartIndex >= 0) loopStartIndex else 0 + } + } delay(2.seconds) _viewState.value = viewState.value.copy(showFinished = true) } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt index 8bead431..632cc264 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt @@ -45,7 +45,7 @@ internal fun ThreeDMap( viewModel.onMapSteadyChange(isSteady) } - val map3dView = remember(mapsConfig) { + val map3dView = remember(mapsConfig.centerLat, mapsConfig.centerLng, mapsConfig.range, mapsConfig.heading, mapsConfig.tilt) { Map3DView(context = context, config = mapsConfig).apply { getMap3DViewAsync( object : OnMap3DViewReadyCallback { @@ -64,7 +64,7 @@ internal fun ThreeDMap( } } - DisposableEffect(lifecycleOwner, map3dView) { + DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_CREATE -> map3dView.onCreate(null) diff --git a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml index 0a9ce978..d3e6e518 100644 --- a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml +++ b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml @@ -48,6 +48,10 @@ Pick a scenario + Seattle: Roadmap to Hybrid Cinematic Orbit (Loop) + UK: The Shard to Tower Bridge (Loop) + Brazil: Copacabana to Ipanema & Arpoador (Loop) + Scenic World Tour: Natural & Cityscapes (Loop) Tower Bridge NYC hybrid NYC satellite