diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt index e604f69..6284faf 100644 --- a/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt @@ -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 = mutableListOf() + init { - val serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0") - featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable) - map.operationalLayers.add(featureLayer) - map.initialViewpoint = Viewpoint(53.14, 8.20, 20000.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) + } } + + /** + * 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.") } \ No newline at end of file diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/components/DropDownMenuBox.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/components/DropDownMenuBox.kt new file mode 100644 index 0000000..9e74d4b --- /dev/null +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/components/DropDownMenuBox.kt @@ -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, + 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() + } + } + } + } +} diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/pages/MapPage.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/pages/MapPage.kt index a6e899d..96d11d3 100644 --- a/app/src/main/java/de/jadehs/strassenschadenpro2/pages/MapPage.kt +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/pages/MapPage.kt @@ -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(), - arcGISMap = mapViewModel.map, - locationDisplay = locationDisplay - ) + Column( + modifier = Modifier + .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 {