diff --git a/samples/validate-utility-network-topology/README.md b/samples/validate-utility-network-topology/README.md new file mode 100644 index 00000000..ee8ecf88 --- /dev/null +++ b/samples/validate-utility-network-topology/README.md @@ -0,0 +1 @@ +# Validate utility network topology diff --git a/samples/validate-utility-network-topology/README.metadata.json b/samples/validate-utility-network-topology/README.metadata.json new file mode 100644 index 00000000..39eb2dcc --- /dev/null +++ b/samples/validate-utility-network-topology/README.metadata.json @@ -0,0 +1,19 @@ +{ + "category": "Utility Networks", + "description": "TODO", + "formal_name": "ValidateUtilityNetworkTopology", + "ignore": false, + "images": [ + "validate-utility-network-topology.png" + ], + "keywords": [ ], + "language": "kotlin", + "redirect_from": "", + "relevant_apis": [ ], + "snippets": [ + "src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/ValidateUtilityNetworkTopologyViewModel.kt", + "src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/ValidateUtilityNetworkTopologyScreen.kt", + "src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/MainActivity.kt" + ], + "title": "Validate utility network topology" +} diff --git a/samples/validate-utility-network-topology/build.gradle.kts b/samples/validate-utility-network-topology/build.gradle.kts new file mode 100644 index 00000000..361e7474 --- /dev/null +++ b/samples/validate-utility-network-topology/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.arcgismaps.android.library) + alias(libs.plugins.arcgismaps.android.library.compose) + alias(libs.plugins.arcgismaps.kotlin.sample) + alias(libs.plugins.gradle.secrets) +} + +secrets { + // this file doesn't contain secrets, it just provides defaults which can be committed into git. + defaultPropertiesFileName = "secrets.defaults.properties" +} + +android { + namespace = "com.esri.arcgismaps.sample.validateutilitynetworktopology" + buildFeatures { + buildConfig = true + } +} + +dependencies { + // Only module specific dependencies needed here +} diff --git a/samples/validate-utility-network-topology/src/main/AndroidManifest.xml b/samples/validate-utility-network-topology/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7f287fc3 --- /dev/null +++ b/samples/validate-utility-network-topology/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/MainActivity.kt b/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/MainActivity.kt new file mode 100644 index 00000000..c826a01c --- /dev/null +++ b/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/MainActivity.kt @@ -0,0 +1,53 @@ +/* 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. + * + */ + +package com.esri.arcgismaps.sample.validateutilitynetworktopology + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import com.arcgismaps.ApiKey +import com.arcgismaps.ArcGISEnvironment +import com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme +import com.esri.arcgismaps.sample.validateutilitynetworktopology.screens.ValidateUtilityNetworkTopologyScreen + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // authentication with an API key or named user is + // required to access basemaps and other location services + ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN) + + setContent { + SampleAppTheme { + ValidateUtilityNetworkTopologyApp() + } + } + } + + @Composable + private fun ValidateUtilityNetworkTopologyApp() { + Surface(color = MaterialTheme.colorScheme.background) { + ValidateUtilityNetworkTopologyScreen( + sampleName = getString(R.string.validate_utility_network_topology_app_name) + ) + } + } +} diff --git a/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/components/ValidateUtilityNetworkTopologyViewModel.kt b/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/components/ValidateUtilityNetworkTopologyViewModel.kt new file mode 100644 index 00000000..8846ac31 --- /dev/null +++ b/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/components/ValidateUtilityNetworkTopologyViewModel.kt @@ -0,0 +1,706 @@ +/* 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. + * + */ + +package com.esri.arcgismaps.sample.validateutilitynetworktopology.components + +/* + * 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. + */ + + +import android.app.Application +import android.util.Log +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.Color +import com.arcgismaps.Guid +import com.arcgismaps.arcgisservices.FeatureServiceSessionType +import com.arcgismaps.arcgisservices.ServiceVersionParameters +import com.arcgismaps.arcgisservices.VersionAccess +import com.arcgismaps.data.ArcGISFeature +import com.arcgismaps.data.CodedValue +import com.arcgismaps.data.CodedValueDomain +import com.arcgismaps.data.Field +import com.arcgismaps.data.ServiceFeatureTable +import com.arcgismaps.data.ServiceGeodatabase +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.geometry.Geometry +import com.arcgismaps.geometry.GeometryEngine +import com.arcgismaps.geometry.Point +import com.arcgismaps.geometry.Polygon +import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeHandler +import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeResponse +import com.arcgismaps.httpcore.authentication.TokenCredential +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.PortalItem +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.labeling.LabelDefinition +import com.arcgismaps.mapping.labeling.SimpleLabelExpression +import com.arcgismaps.mapping.layers.FeatureLayer +import com.arcgismaps.mapping.symbology.TextSymbol +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.ScreenCoordinate +import com.arcgismaps.portal.Portal +import com.arcgismaps.tasks.geoprocessing.GeoprocessingExecutionType +import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import com.arcgismaps.utilitynetworks.UtilityElement +import com.arcgismaps.utilitynetworks.UtilityElementTraceResult +import com.arcgismaps.utilitynetworks.UtilityNetwork +import com.arcgismaps.utilitynetworks.UtilityTraceParameters +import com.arcgismaps.utilitynetworks.UtilityTraceType +import com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModel +import com.esri.arcgismaps.sample.validateutilitynetworktopology.components.ValidateUtilityNetworkTopologyViewModel.Companion.PASSWORD +import com.esri.arcgismaps.sample.validateutilitynetworktopology.components.ValidateUtilityNetworkTopologyViewModel.Companion.USERNAME +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.UUID + +class ValidateUtilityNetworkTopologyViewModel(application: Application) : + AndroidViewModel(application) { + + // The ArcGISMap containing the Naperville Electric web map + val arcGISMap = ArcGISMap( + item = PortalItem( + itemId = NAPERVILLE_ELECTRIC_WEBMAP_ITEM_ID, + portal = Portal( + url = SAMPLE_SERVER_7_PORTAL, + connection = Portal.Connection.Authenticated + ) + ) + ).apply { + initialViewpoint = Viewpoint(center = Point(-9815160.0, 5128880.0), scale = 3640.0) + } + + // Used to update map viewpoint and identify layers on tap + val mapViewProxy = MapViewProxy() + + // Shows the starting location for tracing or other markers if desired + val graphicsOverlay = GraphicsOverlay() + + // The utility network used for tracing + private val utilityNetwork: UtilityNetwork + get() = arcGISMap.utilityNetworks.first() + + // ServiceGeodatabase from the utility network, used for editing and for version management + private var serviceGeodatabase: ServiceGeodatabase? = null + + // Basic downstream trace parameters for the sample, once loaded + private var traceParameters: UtilityTraceParameters? = null + + // Whether we can call "Get State" (checking utility network capabilities) + private val _canGetState = MutableStateFlow(false) + val canGetState = _canGetState.asStateFlow() + + // Whether we can perform a trace right now (depends on network topology) + private val _canTrace = MutableStateFlow(false) + val canTrace = _canTrace.asStateFlow() + + // Whether we can validate the utility network topology (when we have dirty areas or errors) + private val _canValidateNetworkTopology = MutableStateFlow(false) + val canValidateNetworkTopology = _canValidateNetworkTopology.asStateFlow() + + // Whether we can clear the current feature selection from the map + private val _canClearSelection = MutableStateFlow(false) + val canClearSelection = _canClearSelection.asStateFlow() + + // A status message or log that is shown in the UI + private val _statusMessage = MutableStateFlow("") + val statusMessage = _statusMessage.asStateFlow() + + // Currently selected feature that the user is editing + private val _selectedFeature = MutableStateFlow(null) + val selectedFeature = _selectedFeature.asStateFlow() + + // Field being edited on the selected feature (e.g. "devicestatus" or "nominalvoltage") + private var selectedField: Field? = null + + // Available coded values from the above field's domain + private val _fieldValueOptions = MutableStateFlow>(emptyList()) + val fieldValueOptions = _fieldValueOptions.asStateFlow() + + // Currently selected coded value from the domain + private val _selectedFieldValue = MutableStateFlow(null) + val selectedFieldValue = _selectedFieldValue.asStateFlow() + + // Keep track of the current visible area of the MapView + private val _currentVisibleArea = MutableStateFlow(Envelope(Point(0.0, 0.0), Point(0.0, 0.0))) + + // Simple message dialog for errors or important info + val messageDialogVM = MessageDialogViewModel() + + /** + * Clears any selected features from the map. + */ + fun clearSelection() { + clearLayerSelections() + _selectedFeature.value = null + selectedField = null + _fieldValueOptions.value = emptyList() + _selectedFieldValue.value = null + _canClearSelection.value = false + _statusMessage.value = INIT_STATUS_MESSAGE + setButtonStatesFromCapabilities() + } + + /** + * Add credentials and loads the map & utility network. Then creates & switches to a + * new version for editing. Setting up the default trace parameters. Adds the dirty area + * table as a feature layer to the map. Then checks the utility network capabilities + * to set button states. + */ + suspend fun initialize() { + Log.e("INITIALIZED","Initialize called") + // Authenticate and load ArcGIS map + setupMap() + // Load utility network, and switch to a new version to allow edits + setupUtilityNetwork() + // Set up the default trace parameters (downstream) + setupTraceParameters() + // Check capabilities from the network definition to enable/disable relevant UI. + setButtonStatesFromCapabilities() + + _statusMessage.value = INIT_STATUS_MESSAGE + } + + private suspend fun setupMap() { + ArcGISEnvironment.apply { + applicationContext = getApplication().applicationContext + authenticationManager.arcGISAuthenticationChallengeHandler = getAuthChallengeHandler() + } + + // Load the Naperville electric web-map + arcGISMap.apply { + // Set the map to load in persistent session mode (workaround for server caching issue) + // https://support.esri.com/en-us/bug/asynchronous-validate-request-for-utility-network-servi-bug-000160443 + loadSettings.featureServiceSessionType = FeatureServiceSessionType.Persistent + }.load().onFailure { + handleError( + title = "Error loading the web-map: ${it.message}", + description = it.cause.toString() + ) + } + } + + /** + * Create a new version and switch to if to allow for attribute editing. + */ + private suspend fun setupUtilityNetwork() { + // Load the utility network associated with the web-map + utilityNetwork.load().onFailure { + handleError( + title = "Error loading the utility network: ${it.message}", + description = it.cause.toString() + ) + } + // Construct version parameters + val parameters = ServiceVersionParameters().apply { + name = "ValidateNetworkTopology_${UUID.randomUUID()}" + description = "Validate network topology with ArcGIS Maps SDK." + access = VersionAccess.Private + } + + // Retrieve the service geodatabase from the utility network + serviceGeodatabase = (utilityNetwork.serviceGeodatabase)?.apply { + // Load the service geodatabase + load().onFailure { + return handleError("Error loading service geodatabase: ${it.message}") + } + // Create a new private service version + val versionInfo = createVersion(parameters).getOrElse { + return handleError("Unable to create version", "${it.message}") + } + // Switch the service geodatabase to the newly created version + switchVersion(versionInfo.name).onFailure { + return handleError("Failed to switch to version", "${it.message}") + } + } + // Add the dirty area table to the map to visualize it + utilityNetwork.dirtyAreaTable?.let { dirtyAreaTable -> + arcGISMap.operationalLayers.add( + element = FeatureLayer.createWithFeatureTable(dirtyAreaTable) + ) + } + + // Add labels to the map to visualize attribute editing + addLabels( + layerName = DEVICE_TABLE_NAME, + fieldName = DEVICE_STATUS_FIELD, + textColor = Color.blue + ) + addLabels( + layerName = LINE_TABLE_NAME, + fieldName = NOMINAL_VOLTAGE_FIELD, + textColor = Color.red + ) + } + + /** + * Downstream trace parameters from a known device location, stopping traversal on an open device. + */ + private fun setupTraceParameters() { + val networkDefinition = utilityNetwork.definition ?: return + val domainNetwork = networkDefinition.getDomainNetwork("ElectricDistribution") ?: return + val mediumVoltageRadial = domainNetwork.getTier("Medium Voltage Radial") ?: return + + // Look up the relevant network source by its table name + val deviceSource = networkDefinition.networkSources.value.first { + it.name == DEVICE_TABLE_NAME + } + + val circuitBreakerGroup = deviceSource.assetGroups.first { + it.name == "Circuit Breaker" + } + + val threePhaseType = circuitBreakerGroup.assetTypes.first { + it.name == "Three Phase" + } + + // Create the element for the known globalID + val startElement = utilityNetwork.createElementOrNull( + assetType = threePhaseType, + globalId = STARTING_LOCATION_GLOBAL_ID + ) ?: return messageDialogVM.showMessageDialog("Error creating utility network element") + + // Find the "Load" terminal + startElement.terminal = threePhaseType.terminalConfiguration?.terminals?.firstOrNull { + it.name == "Load" + } + + // Create trace parameters for a downstream trace from the element + traceParameters = UtilityTraceParameters( + traceType = UtilityTraceType.Downstream, + startingLocations = listOf(startElement) + ).apply { + // Copy the default trace config from that tier + traceConfiguration = mediumVoltageRadial.getDefaultTraceConfiguration() + } + } + + /** + * Enable or disable the main action buttons based on the network definition's capabilities. + */ + private fun setButtonStatesFromCapabilities() { + utilityNetwork.definition?.capabilities?.apply { + _canGetState.value = supportsNetworkState + _canTrace.value = supportsTrace + _canValidateNetworkTopology.value = supportsValidateNetworkTopology + } + } + + /** + * Adds labels for a given field name to a layer with a given name. + */ + private fun addLabels(layerName: String, fieldName: String, textColor: Color) { + // Create a expression for the label using the given field name + val expression = SimpleLabelExpression(simpleExpression = "[$fieldName]") + + // Create a symbol for label's text using the given color + val symbol = TextSymbol().apply { + color = textColor + size = 12f + haloColor = Color.white + haloWidth = 2f + } + + // Create the definition from the expression and text symbol + val definition = LabelDefinition(labelExpression = expression, textSymbol = symbol) + + // Add the definition to the map layer with the given layer name. + val layer = arcGISMap.operationalLayers.first { it.name == layerName } as FeatureLayer + layer.labelDefinitions.add(definition) + layer.labelsEnabled = true + } + + /** + * Gets the current state of the utility network (hasDirtyAreas, hasErrors, isNetworkTopologyEnabled). + */ + fun getState() { + _statusMessage.value = "Getting utility network state…" + viewModelScope.launch { + val networkState = utilityNetwork.getState().getOrElse { + return@launch handleError("Get state failed: ${it.message}") + } + // Update to true if there are unsaved changes or errors in the utility network state + _canValidateNetworkTopology.value = networkState.hasDirtyAreas || networkState.hasErrors + // Update trace availability + _canTrace.value = networkState.isNetworkTopologyEnabled + // Update contextual hint + val tip = if (_canValidateNetworkTopology.value) + "Tap Validate before trace or expect a trace error." + else "Tap on a feature to edit, or tap Trace." + _statusMessage.value = buildString { + appendLine("Utility Network State:") + appendLine("Has dirty areas: ${networkState.hasDirtyAreas}") + appendLine("Has errors: ${networkState.hasErrors}") + appendLine("Network topology enabled: ${networkState.isNetworkTopologyEnabled}") + appendLine(tip) + } + } + } + + /** + * Validates the utility network topology in the visible map extent to check + * for check dirty areas and identify errors in the network topology. + */ + fun validateNetworkTopology() { + _statusMessage.value = "Validating utility network topology…" + // Create and start the utility network validation job using the current visible extent + val utilityNetworkValidationJob = utilityNetwork.validateNetworkTopology( + extent = _currentVisibleArea.value, + executionType = GeoprocessingExecutionType.SynchronousExecute + ).apply { start() } + viewModelScope.launch { + // Retrieve the result from the job + val validationResult = utilityNetworkValidationJob.result().getOrElse { + return@launch handleError("Validation job error: ${it.message}") + } + // After validation, check if dirty areas remain. + _canValidateNetworkTopology.value = validationResult.hasDirtyAreas + _statusMessage.value = buildString { + appendLine("Network Validation Result") + appendLine("Has dirty areas: ${validationResult.hasDirtyAreas}") + appendLine("Has errors: ${validationResult.hasErrors}") + appendLine("Tap \"Get State\" to check the updated network state.") + } + } + } + + /** + * Identify a single feature from a tap on the map, if it belongs to the device or line layer, + * then gather the coded-value domain from the relevant field to allow editing. + */ + fun identifyFeatureAt(screenCoordinate: ScreenCoordinate, selectedFieldAlias: (String) -> Unit) { + viewModelScope.launch { + // Perform an identify operation at the given screen coords + val identifyLayerResults = mapViewProxy.identifyLayers( + screenCoordinate = screenCoordinate, + tolerance = IDENTIFY_TOLERANCE.dp, + returnPopupsOnly = false, + maximumResults = 1 + ).getOrElse { return@launch handleError("Select feature failed: ${it.message}") } + + // Find the first result from identify results for device/line layers + val identifyLayerResult = identifyLayerResults.firstOrNull { layerResult -> + val layerName = layerResult.layerContent.name + layerName == DEVICE_TABLE_NAME || layerName == LINE_TABLE_NAME + } + + // Find the first ArcGISFeature from results + val foundFeature = identifyLayerResult?.geoElements?.firstOrNull() as? ArcGISFeature + + // Return if no feature was identified + if (foundFeature == null) { + _statusMessage.value = + "No feature identified. Tap a feature in the device or line layer." + return@launch + } + + // Find the coded-value domain field to edit + val (fieldName, fieldAlias) = when (foundFeature.featureTable?.tableName) { + DEVICE_TABLE_NAME -> DEVICE_STATUS_FIELD to "Device Status" + LINE_TABLE_NAME -> NOMINAL_VOLTAGE_FIELD to "Nominal Voltage" + else -> null to null + } + if (fieldName == null) { + _statusMessage.value = "Selected feature is not editable." + return@launch + } + + // Attempt to retrieve the field from the feature table + val tableField = foundFeature.featureTable?.fields?.firstOrNull { + it.name == fieldName + } ?: return@launch handleError("Field '$fieldName' not found in feature table.") + + // Obtain the list of coded value fields in the selected feature + val codedValues = (tableField.domain as? CodedValueDomain)?.codedValues ?: emptyList() + + // Update the currently selected field that the user is editing + selectedField = tableField + + // Update the list of field value options of the selected feature + _fieldValueOptions.value = codedValues + + // Retrieve the current attribute + val currentValue = foundFeature.attributes[fieldName] + val matchedCode = codedValues.find { domainValue -> + valuesAreEqual(domainValue.code, currentValue) + } + + _selectedFieldValue.value = matchedCode + + // clear any previous selection + clearLayerSelections() + + // Select the identified feature on its layer + (foundFeature.featureTable?.layer as? FeatureLayer)?.selectFeature(foundFeature) + + // Update the currently selected feature that the user is editing + _selectedFeature.value = foundFeature + + _canClearSelection.value = true + + _statusMessage.value = "Select a new '$fieldAlias' value, then tap 'Apply.'" + selectedFieldAlias(fieldAlias.toString()) + } + } + + /** + * Runs a simple downstream trace from the previously configured trace parameters. + * Selects any features found by the trace. + */ + fun trace() { + viewModelScope.launch { + clearLayerSelections() // Clear previous selections + + _statusMessage.value = "Running a downstream trace…" + + val params = traceParameters ?: return@launch handleError("Trace parameters not set.") + + val traceResults = utilityNetwork.trace(params).getOrElse { + return@launch handleError("Trace failed", it.message + "\n" + it.cause) + } + + val elementTraceResult = traceResults.firstOrNull { + it is UtilityElementTraceResult + } as? UtilityElementTraceResult ?: return@launch + + if (elementTraceResult.elements.isEmpty()) { + _statusMessage.value = "Trace completed: 0 elements found." + return@launch + } + + // Group elements by which layer/table they belong to and select them. + selectTraceResultElements(elementTraceResult.elements) + + _statusMessage.value = + "Trace completed: ${elementTraceResult.elements.size} elements found." + } + } + + /** + * Select all features that match the given list of utility elements found by the trace. + */ + private suspend fun selectTraceResultElements(elements: List) { + val selectedTraceGeometryList = mutableListOf() + arcGISMap.operationalLayers.filterIsInstance().forEach { layer -> + val matchingElements = elements.filter { element -> + element.networkSource.featureTable.tableName == layer.featureTable?.tableName + } + + if (matchingElements.isNotEmpty()) { + // Query the list of matching features from the network + val featureResult = utilityNetwork + .getFeaturesForElements(matchingElements) + .getOrElse { + return handleError( + title = "Error retrieving features for trace result", + description = it.message + "\n" + it.cause + ) + } + // Select the list of features returned from the query + layer.selectFeatures(featureResult) + // Zoom to the union geometry of all selected features + selectedTraceGeometryList.addAll(featureResult.mapNotNull { it.geometry }) + Log.e("Added", "Added geometry: ${selectedTraceGeometryList.size}") + } + } + + // Calculate the union geometry of all the selected features + val unionGeometry = GeometryEngine.unionOrNull(selectedTraceGeometryList) + + // Set the viewpoint of the map to the result + unionGeometry?.let { + mapViewProxy.setViewpointGeometry(boundingGeometry = it, paddingInDips = 20.0) + } + + _canClearSelection.value = true + } + + /** + * Update the feature with the new coded value and apply the edit to the feature service. + */ + fun applyEdits() { + // Apply the local edits to the server + _statusMessage.value = "Applying edits…" + + val feature = _selectedFeature.value ?: return + + val serviceFeatureTable = feature.featureTable as? ServiceFeatureTable + ?: return handleError("Feature is not from a service feature table.") + + val newCode = _selectedFieldValue.value?.code + ?: return handleError("No new coded value selected.") + + // Update the feature's attribute + val fieldName = selectedField?.name ?: return + _statusMessage.value = "Updating feature attribute…" + feature.attributes[fieldName] = newCode + + viewModelScope.launch { + serviceFeatureTable.updateFeature(feature).getOrElse { + return@launch handleError("Failed to update feature", it.message + "\n" + it.cause) + } + + val utilityNetworkServiceGeodatabase = serviceGeodatabase + ?: return@launch handleError("ServiceGeodatabase not found.") + + val editResults = utilityNetworkServiceGeodatabase.applyEdits().getOrElse { + return@launch handleError("Apply edits failed: ${it.message}") + } + + // Check for any feature edit errors + val hadErrors = editResults.any { tableEditResult -> + tableEditResult.editResults.any { featureEditResult -> featureEditResult.completedWithErrors } + } + + if (hadErrors) { + _statusMessage.value = "Apply edits completed with error(s)." + } else { + _statusMessage.value = """ + Edits applied successfully. + Tap "Get State" to see if the utility network is now dirty. + """.trimIndent() + // Typically, once you've edited, you may want to validate the network again. + _canValidateNetworkTopology.value = true + } + } + } + + /** + * Clear the selection on all FeatureLayers in the map. + */ + private fun clearLayerSelections() { + arcGISMap.operationalLayers.filterIsInstance().forEach { layer -> + layer.clearSelection() + } + } + + /** + * Compare two attribute values for equality, accounting for domain code types. + */ + private fun valuesAreEqual(lhs: Any?, rhs: Any?): Boolean { + // Basic checks + if (lhs == null && rhs == null) return true + if (lhs == null || rhs == null) return false + + // Convert to string, Int, or Double for some common domain code use-cases + return when (lhs) { + is Number -> lhs.toDouble() == (rhs as? Number)?.toDouble() + else -> lhs == rhs + } + } + + fun updateSelectedValue(codedValue: CodedValue) { + _selectedFieldValue.value = codedValue + } + + fun updateVisibleArea(polygon: Polygon) { + _currentVisibleArea.value = polygon.extent + Log.e("VISIBLEAREA", "Current visible area updated.") + } + + private fun handleError(title: String, description: String = "") { + reset() + // _traceState.value = TraceState.TRACE_FAILED + messageDialogVM.showMessageDialog(title, description) + } + + /** + * Resets the trace, removing graphics and clearing selections. + */ + fun reset() { + arcGISMap.operationalLayers + .filterIsInstance() + .forEach { it.clearSelection() } + graphicsOverlay.graphics.clear() + //_traceState.value = TraceState.ADD_STARTING_POINT + _canTrace.value = false + //_selectedTraceType.value = UtilityTraceType.Connected + //_selectedTerminalConfigurationIndex.value = null + //_selectedPointType.value = PointType.Start + //_terminalConfigurationOptions.value = listOf() + } + + companion object { + // Public credentials for the data in this sample (Editor user) + const val USERNAME = "editor01" + const val PASSWORD = "S7#i2LWmYH75" + + // The Naperville electric web map on sample server 7 + private const val NAPERVILLE_ELECTRIC_WEBMAP_ITEM_ID = "6e3fc6db3d0b4e6589eb4097eb3e5b9b" + private const val SAMPLE_SERVER_7_PORTAL = "https://sampleserver7.arcgisonline.com/portal/" + + // The relevant table names/fields in Naperville electric + private const val DEVICE_TABLE_NAME = "Electric Distribution Device" + private const val DEVICE_STATUS_FIELD = "devicestatus" + private const val LINE_TABLE_NAME = "Electric Distribution Line" + private const val NOMINAL_VOLTAGE_FIELD = "nominalvoltage" + + // The known device's global ID used for the default starting location + private val STARTING_LOCATION_GLOBAL_ID = Guid("1CAF7740-0BF4-4113-8DB2-654E18800028") + + // Tolerance in device-independent pixels for identify + private const val IDENTIFY_TOLERANCE = 10f + + private const val INIT_STATUS_MESSAGE = """ + Utility network loaded. + Tap on a feature to edit its domain-coded field. + Then tap "Apply" to update the value on the server. + Use "Get State" to see if validating is needed or if tracing is available. + """ + } +} + +/** + * Returns a [ArcGISAuthenticationChallengeHandler] to access the utility network URL. + */ +private fun getAuthChallengeHandler(): ArcGISAuthenticationChallengeHandler { + return ArcGISAuthenticationChallengeHandler { challenge -> + val result: Result = runBlocking { + TokenCredential.create(challenge.requestUrl, USERNAME, PASSWORD, 0) + } + if (result.getOrNull() != null) { + val credential = result.getOrNull() + return@ArcGISAuthenticationChallengeHandler ArcGISAuthenticationChallengeResponse + .ContinueWithCredential(credential!!) + } else { + val ex = result.exceptionOrNull() + return@ArcGISAuthenticationChallengeHandler ArcGISAuthenticationChallengeResponse + .ContinueAndFailWithError(ex!!) + } + } +} + +private val Color.Companion.blue: Color + get() { + return fromRgba(0, 0, 255, 255) + } \ No newline at end of file diff --git a/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/screens/ValidateUtilityNetworkTopologyScreen.kt b/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/screens/ValidateUtilityNetworkTopologyScreen.kt new file mode 100644 index 00000000..5ea3711d --- /dev/null +++ b/samples/validate-utility-network-topology/src/main/java/com/esri/arcgismaps/sample/validateutilitynetworktopology/screens/ValidateUtilityNetworkTopologyScreen.kt @@ -0,0 +1,344 @@ +/* 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 com.esri.arcgismaps.sample.validateutilitynetworktopology.screens + +import android.content.res.Configuration +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.arcgismaps.Color +import com.arcgismaps.data.CodedValue +import com.arcgismaps.mapping.view.SelectionProperties +import com.arcgismaps.toolkit.geoviewcompose.MapView +import com.esri.arcgismaps.sample.sampleslib.components.MessageDialog +import com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar +import com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme +import com.esri.arcgismaps.sample.validateutilitynetworktopology.components.ValidateUtilityNetworkTopologyViewModel + +/** + * Main screen layout for the sample app + */ +@Composable +fun ValidateUtilityNetworkTopologyScreen(sampleName: String) { + val viewModel: ValidateUtilityNetworkTopologyViewModel = viewModel() + + // On first composition, initialize the sample. + LaunchedEffect(Unit) { + viewModel.initialize() + } + + // Collect UI states from the ViewModel + val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle("") + val canGetState by viewModel.canGetState.collectAsStateWithLifecycle(false) + val canValidate by viewModel.canValidateNetworkTopology.collectAsStateWithLifecycle(false) + val canTrace by viewModel.canTrace.collectAsStateWithLifecycle(false) + val canClearSelection by viewModel.canClearSelection.collectAsStateWithLifecycle(false) + + // For editing a feature's coded-value domain + var fieldValueOptions by remember { mutableStateOf>(listOf()) } + val selectedFieldValue by viewModel.selectedFieldValue.collectAsStateWithLifecycle(null) + val selectedFeature by viewModel.selectedFeature.collectAsStateWithLifecycle(null) + var selectedFieldAlias by remember { mutableStateOf("") } + + // If we are currently editing an attribute, display "Edit Feature" dialog. + var isEditingFeature by remember { mutableStateOf(false) } + + // Whenever the selectedFeature changes from null to non-null, we show the "edit" UI. + LaunchedEffect(selectedFeature) { + isEditingFeature = selectedFeature != null + fieldValueOptions = viewModel.fieldValueOptions.value + } + + Scaffold( + topBar = { SampleTopAppBar(title = sampleName) }, + content = { padding -> + Column(modifier = Modifier.padding(padding)) { + Box(modifier = Modifier.weight(1f)) { + MapView( + modifier = Modifier.fillMaxSize(), + arcGISMap = viewModel.arcGISMap, + mapViewProxy = viewModel.mapViewProxy, + graphicsOverlays = listOf(viewModel.graphicsOverlay), + selectionProperties = SelectionProperties(color = Color.yellow), + onVisibleAreaChanged = viewModel::updateVisibleArea, + onSingleTapConfirmed = { tapEvent -> + // Identify feature at the tapped location + viewModel.identifyFeatureAt( + screenCoordinate = tapEvent.screenCoordinate, + selectedFieldAlias = { selectedFieldAlias = it } + ) + } + ) + + Log.e("RECOMPOSING", "MapView recomposed.") + + // Status/Message text pinned near the top, with marquee if needed. + Text( + text = statusMessage, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .align(Alignment.TopCenter) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)) + .padding(8.dp) + ) + } + + // A row of sample operations: get state, validate, trace, clear + // along with editing which is triggered by a selected feature. + BottomRowOptions( + isGetStateEnabled = canGetState, + isValidateEnabled = canValidate, + isTraceEnabled = canTrace, + isClearSelectionEnabled = canClearSelection, + onGetStateSelected = viewModel::getState, + onValidateSelected = viewModel::validateNetworkTopology, + onTraceSelected = viewModel::trace, + onClearSelected = viewModel::clearSelection + ) + } + + // If editing a feature, show a dialog with coded value choices and an "Apply" button. + if (isEditingFeature && fieldValueOptions.isNotEmpty()) { + ModalBottomSheet( + onDismissRequest = { + viewModel.clearSelection() + isEditingFeature = false + } + ) { + EditFeatureFieldOptions( + selectedFieldValueName = selectedFieldValue?.name ?: "(None)", + fieldAliasName = selectedFieldAlias, + fieldValueOptions = fieldValueOptions, + onNewFieldValueSelected = viewModel::updateSelectedValue, + onCancelButtonSelected = { + viewModel.clearSelection() + isEditingFeature = false + }, + onApplyEditsSelected = { + viewModel.applyEdits() + isEditingFeature = false + } + ) + } + } + + // Handle error or info dialogs from the ViewModel + viewModel.messageDialogVM.apply { + if (dialogStatus) { + MessageDialog( + title = messageTitle, + description = messageDescription, + onDismissRequest = ::dismissDialog + ) + } + } + } + ) +} + +@Composable +fun EditFeatureFieldOptions( + selectedFieldValueName: String, + fieldAliasName: String, + fieldValueOptions: List, + onNewFieldValueSelected: (CodedValue) -> Unit, + onCancelButtonSelected: () -> Unit, + onApplyEditsSelected: () -> Unit +) { + Column( + modifier = Modifier + .wrapContentSize() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = onCancelButtonSelected) { Text("Cancel") } + Text( + text = "Edit Feature", + style = MaterialTheme.typography.titleMedium + ) + Button(onClick = onApplyEditsSelected) { Text("Apply") } + } + Text( + modifier = Modifier.padding(horizontal = 24.dp), + text = fieldAliasName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Card(modifier = Modifier.padding(horizontal = 24.dp)) { + LazyColumn { + fieldValueOptions.forEachIndexed { index, codedValue -> + item { + Column(modifier = Modifier.clickable { onNewFieldValueSelected(codedValue) }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = codedValue.name, + fontWeight = + if (codedValue.name == selectedFieldValueName) + FontWeight.Bold + else FontWeight.Normal + ) + if (codedValue.name == selectedFieldValueName) + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected field value" + ) + } + if (index <= fieldValueOptions.lastIndex) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +fun BottomRowOptions( + isGetStateEnabled: Boolean, + isValidateEnabled: Boolean, + isTraceEnabled: Boolean, + isClearSelectionEnabled: Boolean, + onGetStateSelected: () -> Unit, + onValidateSelected: () -> Unit, + onTraceSelected: () -> Unit, + onClearSelected: () -> Unit +) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 12.dp), + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.Center + ) { + item { + Button( + onClick = onGetStateSelected, + enabled = isGetStateEnabled + ) { Text("Get State") } + } + + item { + Button( + onClick = onValidateSelected, + enabled = isValidateEnabled + ) { Text("Validate") } + } + + item { + Button( + onClick = onClearSelected, + enabled = isClearSelectionEnabled + ) { Text("Clear") } + } + + item { + Button( + onClick = onTraceSelected, + enabled = isTraceEnabled + ) { Text("Trace") } + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun PreviewValidateUtilityNetworkEditDialog() { + SampleAppTheme { + Surface { + EditFeatureFieldOptions( + selectedFieldValueName = "14.4 KV", + fieldValueOptions = listOf(), + onNewFieldValueSelected = { }, + onCancelButtonSelected = { }, + onApplyEditsSelected = { }, + fieldAliasName = "Nominal Voltage" + ) + } + } +} + + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun PreviewValidateUtilityNetworkOptions() { + SampleAppTheme { + Surface { + BottomRowOptions( + isGetStateEnabled = true, + isValidateEnabled = true, + isTraceEnabled = true, + isClearSelectionEnabled = true, + onGetStateSelected = { }, + onValidateSelected = { }, + onTraceSelected = { }, + onClearSelected = { } + ) + } + } +} diff --git a/samples/validate-utility-network-topology/src/main/res/values/strings.xml b/samples/validate-utility-network-topology/src/main/res/values/strings.xml new file mode 100644 index 00000000..a55e1b73 --- /dev/null +++ b/samples/validate-utility-network-topology/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Validate utility network topology + diff --git a/samples/validate-utility-network-topology/validate-utility-network-topology.png b/samples/validate-utility-network-topology/validate-utility-network-topology.png new file mode 100644 index 00000000..84b726c2 Binary files /dev/null and b/samples/validate-utility-network-topology/validate-utility-network-topology.png differ