Add feature management functionality to MapPage and MapViewModel with feature creation, deletion, attribute updates, geometry updates, and dropdown UI component integration.
This commit is contained in:
@@ -1,26 +1,270 @@
|
||||
package de.jadehs.strassenschadenpro2
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.arcgismaps.LoadStatus
|
||||
import com.arcgismaps.data.ArcGISFeature
|
||||
import com.arcgismaps.data.CodedValueDomain
|
||||
import com.arcgismaps.data.ServiceFeatureTable
|
||||
import com.arcgismaps.geometry.GeometryEngine
|
||||
import com.arcgismaps.mapping.ArcGISMap
|
||||
import com.arcgismaps.mapping.BasemapStyle
|
||||
import com.arcgismaps.mapping.Viewpoint
|
||||
import com.arcgismaps.mapping.layers.FeatureLayer
|
||||
import com.arcgismaps.mapping.view.ScreenCoordinate
|
||||
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
|
||||
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
|
||||
import de.jadehs.strassenschadenpro2.pages.SettingsPage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MapViewModel(application: Application): AndroidViewModel(application) {
|
||||
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle)
|
||||
|
||||
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
|
||||
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
||||
}
|
||||
// Hold a reference to the selected feature.
|
||||
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
|
||||
val mapViewProxy = MapViewProxy()
|
||||
|
||||
var currentFeatureOperation by mutableStateOf(FeatureOperationType.CREATE)
|
||||
|
||||
lateinit var featureLayer: FeatureLayer
|
||||
|
||||
// Create a snackbar message to display the result of feature operations.
|
||||
var snackBarMessage: String by mutableStateOf("")
|
||||
|
||||
lateinit var serviceFeatureTable: ServiceFeatureTable
|
||||
|
||||
var currentDamageType by mutableStateOf("")
|
||||
|
||||
// The list of damage types to update the feature attribute.
|
||||
var damageTypeList: List<String> = mutableListOf()
|
||||
|
||||
init {
|
||||
val serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0")
|
||||
viewModelScope.launch {
|
||||
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0")
|
||||
serviceFeatureTable.load().onSuccess {
|
||||
// Get the field from the feature table that will be updated.
|
||||
val typeDamageField = serviceFeatureTable.fields.first { it.name == "Typ" }
|
||||
// Get the coded value domain for the field.
|
||||
val attributeDomain = typeDamageField.domain as CodedValueDomain
|
||||
// Add the damage types to the list.
|
||||
attributeDomain.codedValues.forEach {
|
||||
damageTypeList += it.name
|
||||
}
|
||||
}
|
||||
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
|
||||
map.operationalLayers.add(featureLayer)
|
||||
map.initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current feature operation to perform based on the selected index from the dropdown. Also, reset feature
|
||||
* selection.
|
||||
*/
|
||||
fun onFeatureOperationSelected(index: Int) {
|
||||
currentFeatureOperation = FeatureOperationType.entries[index]
|
||||
// Reset the selected feature when the operation changes.
|
||||
featureLayer?.clearSelection()
|
||||
selectedFeature = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Directs the behaviour of tap's on the map view.
|
||||
*/
|
||||
fun onTap(singleTapConfirmedEvent: SingleTapConfirmedEvent) {
|
||||
if (featureLayer?.loadStatus?.value != LoadStatus.Loaded) {
|
||||
snackBarMessage = "Layer not loaded!"
|
||||
return
|
||||
}
|
||||
when (currentFeatureOperation) {
|
||||
FeatureOperationType.CREATE -> createFeatureAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
FeatureOperationType.DELETE -> deleteFeatureAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
FeatureOperationType.UPDATE_ATTRIBUTE -> selectFeatureForAttributeEditAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
FeatureOperationType.UPDATE_GEOMETRY -> updateFeatureGeometryAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new feature at the tapped location with some default attributes
|
||||
*/
|
||||
private fun createFeatureAt(screenCoordinate: ScreenCoordinate) {
|
||||
// Create the feature.
|
||||
val feature = serviceFeatureTable?.createFeature()?.apply {
|
||||
// Get the normalized geometry for the tapped location and use it as the feature's geometry.
|
||||
mapViewProxy.screenToLocationOrNull(screenCoordinate)?.let { mapPoint ->
|
||||
geometry = GeometryEngine.normalizeCentralMeridian(mapPoint)
|
||||
// Set feature attributes.
|
||||
attributes["Typ"] = "SL"
|
||||
attributes["Beschreibung"] = "Earthquake"
|
||||
}
|
||||
}
|
||||
feature?.let {
|
||||
viewModelScope.launch {
|
||||
// Add the feature to the table.
|
||||
serviceFeatureTable?.addFeature(it)
|
||||
// Apply the edits to the service on the service geodatabase.
|
||||
serviceFeatureTable?.applyEdits()
|
||||
// Update the feature to get the updated objectid - a temporary ID is used before the feature is added.
|
||||
it.refresh()
|
||||
// Confirm feature addition.
|
||||
snackBarMessage = "Created feature ${it.attributes["objectid"]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a feature at the tapped location in preparation for deletion.
|
||||
*/
|
||||
private fun deleteFeatureAt(screenCoordinate: ScreenCoordinate) {
|
||||
featureLayer?.let { featureLayer ->
|
||||
// Clear any existing selection.
|
||||
featureLayer.clearSelection()
|
||||
selectedFeature = null
|
||||
viewModelScope.launch {
|
||||
// Determine if a user tapped on a feature.
|
||||
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
|
||||
selectedFeature = (identifyResult.geoElements.firstOrNull() as? ArcGISFeature)?.also {
|
||||
featureLayer.selectFeature(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the selected feature from the feature table and service geodatabase.
|
||||
*/
|
||||
fun deleteSelectedFeature() {
|
||||
selectedFeature?.let {
|
||||
// Get the feature's object id.
|
||||
val featureId = it.attributes["objectid"] as Long
|
||||
viewModelScope.launch {
|
||||
// Delete the feature from the feature table.
|
||||
serviceFeatureTable?.deleteFeature(it)?.onSuccess {
|
||||
snackBarMessage = "Deleted feature $featureId"
|
||||
// Apply the edits to the service geodatabase.
|
||||
serviceFeatureTable?.applyEdits()
|
||||
selectedFeature = null
|
||||
}?.onFailure {
|
||||
snackBarMessage = "Failed to delete feature $featureId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a feature at the tapped location in preparation for attribute editing.
|
||||
*/
|
||||
private fun selectFeatureForAttributeEditAt(screenCoordinate: ScreenCoordinate) {
|
||||
featureLayer?.let { featureLayer ->
|
||||
// Clear any existing selection.
|
||||
featureLayer.clearSelection()
|
||||
selectedFeature = null
|
||||
viewModelScope.launch {
|
||||
// Determine if a user tapped on a feature.
|
||||
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
|
||||
// Get the identified feature.
|
||||
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
|
||||
identifiedFeature?.let {
|
||||
val currentAttributeValue = it.attributes["Typ"] as String
|
||||
currentDamageType = currentAttributeValue
|
||||
selectedFeature = it.also {
|
||||
featureLayer.selectFeature(it)
|
||||
}
|
||||
} ?: run {
|
||||
// Reset damage type if no feature identified.
|
||||
currentDamageType = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the attribute value of the selected feature to the new value from the new damage type.
|
||||
*/
|
||||
fun onDamageTypeSelected(index: Int) {
|
||||
// Get the new value.
|
||||
currentDamageType = damageTypeList[index]
|
||||
selectedFeature?.let { selectedFeature ->
|
||||
viewModelScope.launch {
|
||||
// Load the feature.
|
||||
selectedFeature.load().onSuccess {
|
||||
// Update the attribute value.
|
||||
selectedFeature.attributes["Typ"] = currentDamageType
|
||||
// Update the table.
|
||||
serviceFeatureTable?.updateFeature(selectedFeature)
|
||||
// Update the service on the service geodatabase.
|
||||
serviceFeatureTable?.applyEdits()?.onSuccess {
|
||||
snackBarMessage =
|
||||
"Updated feature ${selectedFeature.attributes["objectid"]} to $currentDamageType"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a feature, if none is selected. If a feature is selected, update its geometry to the tapped location.
|
||||
*/
|
||||
private fun updateFeatureGeometryAt(screenCoordinate: ScreenCoordinate) {
|
||||
|
||||
featureLayer?.let { featureLayer ->
|
||||
when (selectedFeature) {
|
||||
// When no feature is selected.
|
||||
null -> {
|
||||
viewModelScope.launch {
|
||||
// Determine if a user tapped on a feature.
|
||||
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
|
||||
// Get the identified feature.
|
||||
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
|
||||
identifiedFeature?.let {
|
||||
selectedFeature = it.also {
|
||||
featureLayer.selectFeature(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// When a feature is selected, update its geometry to the tapped location.
|
||||
else -> {
|
||||
mapViewProxy.screenToLocationOrNull(screenCoordinate)?.let { mapPoint ->
|
||||
// Normalize the point - needed when the tapped location is over the international date line.
|
||||
val destinationPoint = GeometryEngine.normalizeCentralMeridian(mapPoint)
|
||||
viewModelScope.launch {
|
||||
selectedFeature?.let { selectedFeature ->
|
||||
// Load the feature.
|
||||
selectedFeature.load().onSuccess {
|
||||
// Update the geometry of the selected feature.
|
||||
selectedFeature.geometry = destinationPoint
|
||||
// Apply the edit to the feature table.
|
||||
serviceFeatureTable?.updateFeature(selectedFeature)
|
||||
// Push the update to the service with the service geodatabase.
|
||||
serviceFeatureTable?.applyEdits()?.onSuccess {
|
||||
snackBarMessage = "Moved feature ${selectedFeature.attributes["objectid"]}"
|
||||
}?.onFailure {
|
||||
snackBarMessage =
|
||||
"Failed to move feature ${selectedFeature.attributes["objectid"]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class FeatureOperationType(val operationName: String, val instruction: String) {
|
||||
CREATE("Create feature", "Tap on the map to create a new feature."),
|
||||
DELETE("Delete feature", "Select an existing feature to delete it."),
|
||||
UPDATE_ATTRIBUTE("Update attribute", "Select an existing feature to edit its attribute."),
|
||||
UPDATE_GEOMETRY("Update geometry", "Select an existing feature and tap the map to move it to a new position.")
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
|
||||
/* Copyright 2025 Esri
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package de.jadehs.strassenschadenpro2.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
/**
|
||||
* Composable component to simplify the usage of an [ExposedDropdownMenuBox].
|
||||
*/
|
||||
@Composable
|
||||
fun DropDownMenuBox(
|
||||
modifier: Modifier = Modifier,
|
||||
textFieldValue: String,
|
||||
textFieldLabel: String,
|
||||
dropDownItemList: List<String>,
|
||||
onIndexSelected: (Int) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
modifier = modifier,
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
TextField(
|
||||
label = { Text(textFieldLabel) },
|
||||
value = textFieldValue,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
dropDownItemList.forEachIndexed { index, scalebarStyle ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(scalebarStyle) },
|
||||
onClick = {
|
||||
onIndexSelected(index)
|
||||
expanded = false
|
||||
})
|
||||
// Show a divider between dropdown menu options
|
||||
if (index < dropDownItemList.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,23 @@ import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.arcgismaps.ApiKey
|
||||
import com.arcgismaps.ArcGISEnvironment
|
||||
@@ -25,7 +34,9 @@ import com.arcgismaps.mapping.Viewpoint
|
||||
import com.arcgismaps.toolkit.geoviewcompose.MapView
|
||||
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
|
||||
import de.jadehs.strassenschadenpro2.BuildConfig
|
||||
import de.jadehs.strassenschadenpro2.FeatureOperationType
|
||||
import de.jadehs.strassenschadenpro2.MapViewModel
|
||||
import de.jadehs.strassenschadenpro2.components.DropDownMenuBox
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -33,9 +44,13 @@ fun MapPage(modifier: Modifier = Modifier, application: Application) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var featureManagementDropdownIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
ArcGISEnvironment.applicationContext = context.applicationContext
|
||||
ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN)
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val locationDisplay = rememberLocationDisplay().apply {
|
||||
setAutoPanMode(LocationDisplayAutoPanMode.Off)
|
||||
}
|
||||
@@ -58,10 +73,60 @@ fun MapPage(modifier: Modifier = Modifier, application: Application) {
|
||||
|
||||
val mapViewModel = remember { MapViewModel(application) }
|
||||
|
||||
MapView(modifier = Modifier.fillMaxSize(),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
MapView(
|
||||
modifier = Modifier.weight(80f),
|
||||
arcGISMap = mapViewModel.map,
|
||||
locationDisplay = locationDisplay
|
||||
locationDisplay = locationDisplay,
|
||||
mapViewProxy = mapViewModel.mapViewProxy,
|
||||
onSingleTapConfirmed = mapViewModel::onTap,
|
||||
) {
|
||||
mapViewModel.selectedFeature?.let { selectedFeature ->
|
||||
// Only show the delete button when on the delete feature operation and a feature is selected.
|
||||
if (mapViewModel.currentFeatureOperation == FeatureOperationType.DELETE) {
|
||||
Callout(geoElement = selectedFeature) {
|
||||
Button(onClick = mapViewModel::deleteSelectedFeature) {
|
||||
Text(text = "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only show the dropdown for damage type when on the update feature operation.
|
||||
if (mapViewModel.currentFeatureOperation == FeatureOperationType.UPDATE_ATTRIBUTE) {
|
||||
Callout(geoElement = selectedFeature) {
|
||||
DropDownMenuBox(
|
||||
modifier = Modifier
|
||||
.padding(8.dp),
|
||||
textFieldLabel = "Select damage type",
|
||||
textFieldValue = mapViewModel.currentDamageType,
|
||||
dropDownItemList = mapViewModel.damageTypeList,
|
||||
onIndexSelected = { index ->
|
||||
if (mapViewModel.selectedFeature != null) {
|
||||
mapViewModel.onDamageTypeSelected(index)
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar("Please select a feature to update")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DropDownMenuBox(
|
||||
modifier = Modifier.weight(20f).padding(end = 8.dp),
|
||||
textFieldLabel = "Feature management operation",
|
||||
textFieldValue = mapViewModel.currentFeatureOperation.operationName,
|
||||
dropDownItemList = FeatureOperationType.entries.map { entry -> entry.operationName },
|
||||
onIndexSelected = { index ->
|
||||
mapViewModel.onFeatureOperationSelected(index)
|
||||
featureManagementDropdownIndex = index
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun checkPermissions(context: Context): Boolean {
|
||||
|
||||
Reference in New Issue
Block a user