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:
2025-11-27 11:17:14 +01:00
parent 3c1c91b5b7
commit 549cf73ede
3 changed files with 402 additions and 10 deletions

View File

@@ -1,26 +1,270 @@
package de.jadehs.strassenschadenpro2 package de.jadehs.strassenschadenpro2
import android.app.Application 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.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.data.ServiceFeatureTable
import com.arcgismaps.geometry.GeometryEngine
import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.Viewpoint import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.mapping.layers.FeatureLayer 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 com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
import de.jadehs.strassenschadenpro2.pages.SettingsPage import de.jadehs.strassenschadenpro2.pages.SettingsPage
import kotlinx.coroutines.launch
class MapViewModel(application: Application): AndroidViewModel(application) { 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() val mapViewProxy = MapViewProxy()
var currentFeatureOperation by mutableStateOf(FeatureOperationType.CREATE)
lateinit var featureLayer: FeatureLayer 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 { init {
val serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0") viewModelScope.launch {
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable) serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0")
map.operationalLayers.add(featureLayer) serviceFeatureTable.load().onSuccess {
map.initialViewpoint = Viewpoint(53.14, 8.20, 20000.0) // 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)
}
} }
/**
* 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.")
} }

View File

@@ -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()
}
}
}
}
}

View File

@@ -7,14 +7,23 @@ import android.content.pm.PackageManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.arcgismaps.ApiKey import com.arcgismaps.ApiKey
import com.arcgismaps.ArcGISEnvironment import com.arcgismaps.ArcGISEnvironment
@@ -25,7 +34,9 @@ import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.toolkit.geoviewcompose.MapView import com.arcgismaps.toolkit.geoviewcompose.MapView
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
import de.jadehs.strassenschadenpro2.BuildConfig import de.jadehs.strassenschadenpro2.BuildConfig
import de.jadehs.strassenschadenpro2.FeatureOperationType
import de.jadehs.strassenschadenpro2.MapViewModel import de.jadehs.strassenschadenpro2.MapViewModel
import de.jadehs.strassenschadenpro2.components.DropDownMenuBox
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -33,9 +44,13 @@ fun MapPage(modifier: Modifier = Modifier, application: Application) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var featureManagementDropdownIndex by remember { mutableIntStateOf(0) }
ArcGISEnvironment.applicationContext = context.applicationContext ArcGISEnvironment.applicationContext = context.applicationContext
ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN) ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN)
val snackbarHostState = remember { SnackbarHostState() }
val locationDisplay = rememberLocationDisplay().apply { val locationDisplay = rememberLocationDisplay().apply {
setAutoPanMode(LocationDisplayAutoPanMode.Off) setAutoPanMode(LocationDisplayAutoPanMode.Off)
} }
@@ -58,10 +73,60 @@ fun MapPage(modifier: Modifier = Modifier, application: Application) {
val mapViewModel = remember { MapViewModel(application) } val mapViewModel = remember { MapViewModel(application) }
MapView(modifier = Modifier.fillMaxSize(), Column(
arcGISMap = mapViewModel.map, modifier = Modifier
locationDisplay = locationDisplay .fillMaxSize(),
) horizontalAlignment = Alignment.CenterHorizontally,
) {
MapView(
modifier = Modifier.weight(80f),
arcGISMap = mapViewModel.map,
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 { fun checkPermissions(context: Context): Boolean {