Skip to content

Commit 6f01a47

Browse files
committed
feat: Dynamic polygon location and sizing with DataStore
- Added Places SDK integration to dynamically set the center of the Animated Polygon based on user search. - Added DataStore bindings in SettingsRepository for persisting polygon dimensions (width/height in miles) and center coordinates. - Added Robolectric and Coroutines test dependencies and created SettingsRepositoryTest to verify persistence logic. - Updated TopAppBar title to 'Animated Polygon with Settings'. - Refined AnimatedPolygonActivity UI with explanatory comments and automatic camera/altitude bounds resetting upon new location selection.
1 parent 91f360d commit 6f01a47

8 files changed

Lines changed: 279 additions & 6 deletions

File tree

Maps3DSamples/advanced/app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,17 @@ dependencies {
143143
debugImplementation(libs.androidx.ui.test.manifest)
144144

145145
implementation(libs.kotlinx.datetime)
146+
testImplementation(libs.kotlinx.coroutines.test)
147+
testImplementation(libs.robolectric)
148+
testImplementation(libs.androidx.test.core)
149+
testImplementation(libs.androidx.junit)
146150
implementation(libs.dagger)
147151
ksp(libs.hilt.android.compiler)
148152
implementation(libs.hilt.android)
149153

150154
implementation(libs.play.services.base)
151155
implementation(libs.play.services.maps3d)
156+
implementation(libs.play.services.places)
152157

153158
testImplementation(libs.google.truth)
154159

Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
4242
import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity
4343
import com.example.advancedmaps3dsamples.animatedpolygon.AnimatedPolygonActivity
4444
import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme
45+
import com.google.android.libraries.places.api.Places
4546
import dagger.hilt.android.AndroidEntryPoint
4647

4748
data class MapSample(@StringRes val label: Int, val clazz: Class<*>)
@@ -57,6 +58,11 @@ private val samples =
5758
class MainActivity : ComponentActivity() {
5859
override fun onCreate(savedInstanceState: Bundle?) {
5960
super.onCreate(savedInstanceState)
61+
62+
if (!Places.isInitialized()) {
63+
Places.initializeWithNewPlacesApiEnabled(applicationContext, BuildConfig.MAPS3D_API_KEY)
64+
}
65+
6066
enableEdgeToEdge()
6167
setContent {
6268
AdvancedMaps3DSamplesTheme {

Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/animatedpolygon/AnimatedPolygonActivity.kt

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,23 @@ import androidx.compose.ui.Alignment
4343
import androidx.compose.ui.Modifier
4444
import androidx.compose.ui.res.stringResource
4545
import androidx.compose.ui.unit.dp
46+
import android.app.Activity
47+
import androidx.activity.result.contract.ActivityResultContracts
48+
import androidx.activity.compose.rememberLauncherForActivityResult
49+
import androidx.compose.material.icons.filled.Search
4650
import com.example.advancedmaps3dsamples.R
4751
import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme
4852
import com.example.advancedmaps3dsamples.utils.toValidCamera
53+
import com.google.android.gms.maps.model.LatLng
54+
import com.google.android.libraries.places.api.model.Place
55+
import com.google.android.libraries.places.widget.Autocomplete
56+
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
4957
import com.google.android.gms.maps3d.model.AltitudeMode
5058
import com.google.android.gms.maps3d.model.Camera
59+
import com.google.android.gms.maps3d.model.camera
5160
import com.google.android.gms.maps3d.model.Map3DMode
5261
import com.google.android.gms.maps3d.model.latLngAltitude
62+
import com.google.android.gms.maps3d.model.LatLngAltitude
5363
import com.google.android.gms.maps3d.model.polygonOptions
5464
import dagger.hilt.android.AndroidEntryPoint
5565
import com.google.android.gms.maps3d.Map3DOptions
@@ -77,6 +87,42 @@ class AnimatedPolygonActivity : ComponentActivity() {
7787
var showSettingsDialog by mutableStateOf(false)
7888

7989
setContent {
90+
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
91+
if (result.resultCode == Activity.RESULT_OK) {
92+
result.data?.let { intent ->
93+
// Retrieve the Place selected by the user.
94+
val place = Autocomplete.getPlaceFromIntent(intent)
95+
place.latLng?.let { latLng ->
96+
// When a new location is picked, we persist the coordinates to DataStore
97+
// so that the 3D polygon's geometry is recalculated around this new center.
98+
viewModel.savePolygonCenter(latLng.latitude, latLng.longitude)
99+
100+
// Reset the user's zoom/altitude bounds. Since we don't know the exact elevation of the new place,
101+
// expanding the bounds from 0 to 15K feet allows them to easily find the polygon.
102+
viewModel.saveMinAltitude(0f)
103+
viewModel.saveMaxAltitude(15000f)
104+
105+
// Immediately fly the camera to the new location to center it in the view,
106+
// overriding whatever camera position the user previously had.
107+
val newCam = camera {
108+
center = latLngAltitude {
109+
latitude = latLng.latitude
110+
longitude = latLng.longitude
111+
altitude = 10000.0
112+
}
113+
heading = 0.0
114+
tilt = 0.0
115+
}
116+
viewModel.setCamera(newCam)
117+
118+
// Force a save of the new camera right away so that if the user backgrounds
119+
// the app before the map becomes "steady", they don't lose this new location.
120+
viewModel.saveCameraSettings(newCam)
121+
}
122+
}
123+
}
124+
}
125+
80126
AdvancedMaps3DSamplesTheme {
81127
Scaffold(
82128
modifier = Modifier.fillMaxSize(),
@@ -87,9 +133,20 @@ class AnimatedPolygonActivity : ComponentActivity() {
87133
titleContentColor = MaterialTheme.colorScheme.primary,
88134
),
89135
title = {
90-
Text(stringResource(R.string.scenarios_altitude_slider))
136+
Text(stringResource(R.string.map_sample_animated_polygon))
91137
},
92138
actions = {
139+
IconButton(onClick = {
140+
val fields = listOf(Place.Field.ID, Place.Field.NAME, Place.Field.LAT_LNG)
141+
val intent = Autocomplete.IntentBuilder(AutocompleteActivityMode.OVERLAY, fields)
142+
.build(this@AnimatedPolygonActivity)
143+
launcher.launch(intent)
144+
}) {
145+
Icon(
146+
imageVector = Icons.Filled.Search,
147+
contentDescription = "Search Location"
148+
)
149+
}
93150
IconButton(onClick = { showSettingsDialog = true }) {
94151
Icon(
95152
imageVector = Icons.Filled.Settings,
@@ -137,6 +194,11 @@ fun AnimatedPolygon(
137194
val sweepSpeed by viewModel.sweepSpeedFlow.collectAsStateWithLifecycle(initialValue = 50f)
138195
val savedCamera by viewModel.savedCameraFlow.collectAsStateWithLifecycle(initialValue = null)
139196

197+
val centerLat by viewModel.polygonCenterLatFlow.collectAsStateWithLifecycle(initialValue = 40.0)
198+
val centerLng by viewModel.polygonCenterLngFlow.collectAsStateWithLifecycle(initialValue = -105.5)
199+
val widthMiles by viewModel.polygonWidthMilesFlow.collectAsStateWithLifecycle(initialValue = 26f)
200+
val heightMiles by viewModel.polygonHeightMilesFlow.collectAsStateWithLifecycle(initialValue = 27f)
201+
140202
// Ensure altitude is clamped within the min and max bounds
141203
var altitudeFeet by remember(minAltitude, maxAltitude) {
142204
mutableFloatStateOf((minAltitude + maxAltitude) / 2f)
@@ -145,12 +207,26 @@ fun AnimatedPolygon(
145207
var isSweeping by remember { mutableStateOf(false) }
146208
var sweepDirection by remember(sweepSpeed) { mutableFloatStateOf(sweepSpeed) }
147209

148-
val boulderPolygonPoints = remember {
210+
val boulderPolygonPoints = remember(centerLat, centerLng, widthMiles, heightMiles) {
211+
val center = LatLng(centerLat, centerLng)
212+
val widthMeters = widthMiles * 1609.34
213+
val heightMeters = heightMiles * 1609.34
214+
215+
// 0=North, 90=East, 180=South, 270=West
216+
val north = com.google.maps.android.SphericalUtil.computeOffset(center, heightMeters / 2.0, 0.0)
217+
val south = com.google.maps.android.SphericalUtil.computeOffset(center, heightMeters / 2.0, 180.0)
218+
219+
// Compute corners by offsetting north/south points east/west
220+
val nw = com.google.maps.android.SphericalUtil.computeOffset(north, widthMeters / 2.0, 270.0)
221+
val ne = com.google.maps.android.SphericalUtil.computeOffset(north, widthMeters / 2.0, 90.0)
222+
val sw = com.google.maps.android.SphericalUtil.computeOffset(south, widthMeters / 2.0, 270.0)
223+
val se = com.google.maps.android.SphericalUtil.computeOffset(south, widthMeters / 2.0, 90.0)
224+
149225
listOf(
150-
latLngAltitude { latitude = 40.20; longitude = -105.26; altitude = 0.0 }, // NW
151-
latLngAltitude { latitude = 40.20; longitude = -105.77; altitude = 0.0 }, // NE
152-
latLngAltitude { latitude = 39.80; longitude = -105.77; altitude = 0.0 }, // SE
153-
latLngAltitude { latitude = 39.80; longitude = -105.26; altitude = 0.0 } // SW
226+
latLngAltitude { latitude = nw.latitude; longitude = nw.longitude; altitude = 0.0 }, // NW
227+
latLngAltitude { latitude = ne.latitude; longitude = ne.longitude; altitude = 0.0 }, // NE
228+
latLngAltitude { latitude = se.latitude; longitude = se.longitude; altitude = 0.0 }, // SE
229+
latLngAltitude { latitude = sw.latitude; longitude = sw.longitude; altitude = 0.0 } // SW
154230
)
155231
}
156232

@@ -256,6 +332,8 @@ fun AnimatedPolygon(
256332
var tempMin by remember(minAltitude) { mutableStateOf(minAltitude.toInt().toString()) }
257333
var tempMax by remember(maxAltitude) { mutableStateOf(maxAltitude.toInt().toString()) }
258334
var tempSpeed by remember(sweepSpeed) { mutableStateOf(sweepSpeed.toInt().toString()) }
335+
var tempWidth by remember(widthMiles) { mutableStateOf(widthMiles.toString()) }
336+
var tempHeight by remember(heightMiles) { mutableStateOf(heightMiles.toString()) }
259337

260338
AlertDialog(
261339
onDismissRequest = onDismissSettings,
@@ -281,6 +359,20 @@ fun AnimatedPolygon(
281359
onValueChange = { tempSpeed = it },
282360
label = { Text("Sweep Speed (ft/frame)") },
283361
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
362+
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
363+
)
364+
OutlinedTextField(
365+
value = tempWidth,
366+
onValueChange = { tempWidth = it },
367+
label = { Text("Width (miles)") },
368+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
369+
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
370+
)
371+
OutlinedTextField(
372+
value = tempHeight,
373+
onValueChange = { tempHeight = it },
374+
label = { Text("Height (miles)") },
375+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
284376
modifier = Modifier.fillMaxWidth()
285377
)
286378
}
@@ -294,6 +386,13 @@ fun AnimatedPolygon(
294386
// Match the current direction (positive or negative) with the new speed magnitude
295387
sweepDirection = if (sweepDirection < 0) -it else it
296388
}
389+
390+
val w = tempWidth.toFloatOrNull()
391+
val h = tempHeight.toFloatOrNull()
392+
if (w != null && h != null) {
393+
viewModel.savePolygonDimensions(w, h)
394+
}
395+
297396
onDismissSettings()
298397
}) {
299398
Text("Save")

Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/data/SettingsRepository.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ import javax.inject.Singleton
1818

1919
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "altitude_settings")
2020

21+
/**
22+
* Repository for persisting application preferences via Jetpack DataStore.
23+
*
24+
* This class abstracts the DataStore implementation from the rest of the application,
25+
* providing a clean, reactive API for observing and updating settings such as
26+
* the 3D polygon's dimensions, sweep speed, and the last known camera position.
27+
* The use of DataStore ensures asynchronous, safe, and consistent reads/writes,
28+
* moving away from traditional SharedPreferences which are prone to UI thread blocking.
29+
*/
2130
@Singleton
2231
class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) {
2332

@@ -39,6 +48,15 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
3948
val CAMERA_TILT_KEY = doublePreferencesKey("camera_tilt")
4049
val CAMERA_ROLL_KEY = doublePreferencesKey("camera_roll")
4150
val CAMERA_RANGE_KEY = doublePreferencesKey("camera_range")
51+
52+
val POLYGON_CENTER_LAT_KEY = doublePreferencesKey("polygon_center_lat")
53+
val POLYGON_CENTER_LNG_KEY = doublePreferencesKey("polygon_center_lng")
54+
val POLYGON_WIDTH_MILES_KEY = floatPreferencesKey("polygon_width_miles")
55+
val POLYGON_HEIGHT_MILES_KEY = floatPreferencesKey("polygon_height_miles")
56+
const val DEFAULT_POLYGON_CENTER_LAT = 40.0
57+
const val DEFAULT_POLYGON_CENTER_LNG = -105.5
58+
const val DEFAULT_POLYGON_WIDTH_MILES = 26f
59+
const val DEFAULT_POLYGON_HEIGHT_MILES = 27f
4260
}
4361

4462
val minAltitudeFlow: Flow<Float> = dataStore.data
@@ -74,6 +92,25 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
7492
}
7593
}
7694

95+
val polygonCenterLatFlow: Flow<Double> = dataStore.data.map { it[POLYGON_CENTER_LAT_KEY] ?: DEFAULT_POLYGON_CENTER_LAT }
96+
val polygonCenterLngFlow: Flow<Double> = dataStore.data.map { it[POLYGON_CENTER_LNG_KEY] ?: DEFAULT_POLYGON_CENTER_LNG }
97+
val polygonWidthMilesFlow: Flow<Float> = dataStore.data.map { it[POLYGON_WIDTH_MILES_KEY] ?: DEFAULT_POLYGON_WIDTH_MILES }
98+
val polygonHeightMilesFlow: Flow<Float> = dataStore.data.map { it[POLYGON_HEIGHT_MILES_KEY] ?: DEFAULT_POLYGON_HEIGHT_MILES }
99+
100+
suspend fun savePolygonCenter(lat: Double, lng: Double) {
101+
dataStore.edit { preferences ->
102+
preferences[POLYGON_CENTER_LAT_KEY] = lat
103+
preferences[POLYGON_CENTER_LNG_KEY] = lng
104+
}
105+
}
106+
107+
suspend fun savePolygonDimensions(width: Float, height: Float) {
108+
dataStore.edit { preferences ->
109+
preferences[POLYGON_WIDTH_MILES_KEY] = width
110+
preferences[POLYGON_HEIGHT_MILES_KEY] = height
111+
}
112+
}
113+
77114
val cameraFlow: Flow<Camera?> = dataStore.data.map { preferences ->
78115
val lat = preferences[CAMERA_LAT_KEY]
79116
val lng = preferences[CAMERA_LNG_KEY]

Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ class ScenariosViewModel @Inject constructor(
8888
val sweepSpeedFlow = settingsRepository.sweepSpeedFlow
8989
val savedCameraFlow = settingsRepository.cameraFlow
9090

91+
val polygonCenterLatFlow = settingsRepository.polygonCenterLatFlow
92+
val polygonCenterLngFlow = settingsRepository.polygonCenterLngFlow
93+
val polygonWidthMilesFlow = settingsRepository.polygonWidthMilesFlow
94+
val polygonHeightMilesFlow = settingsRepository.polygonHeightMilesFlow
95+
9196
fun saveMinAltitude(min: Float) = viewModelScope.launch {
9297
settingsRepository.saveMinAltitude(min)
9398
}
@@ -104,6 +109,14 @@ class ScenariosViewModel @Inject constructor(
104109
settingsRepository.saveCamera(camera)
105110
}
106111

112+
fun savePolygonCenter(lat: Double, lng: Double) = viewModelScope.launch {
113+
settingsRepository.savePolygonCenter(lat, lng)
114+
}
115+
116+
fun savePolygonDimensions(width: Float, height: Float) = viewModelScope.launch {
117+
settingsRepository.savePolygonDimensions(width, height)
118+
}
119+
107120
init {
108121
viewModelScope.launch {
109122
viewState

Maps3DSamples/advanced/app/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
<string name="map_sample_title" translatable="false">3D Map Samples</string>
2121
<string name="map_sample_scenarios" translatable="false">Scenarios (video visuals)</string>
22+
<string name="map_sample_animated_polygon" translatable="false">Animated Polygon with Settings</string>
2223

2324
<!--
2425
Distance in feet, formatted to 0 decimal places.

0 commit comments

Comments
 (0)