409 lines
17 KiB
Kotlin
409 lines
17 KiB
Kotlin
import android.app.Application
|
|
import android.graphics.Bitmap
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.graphics.ImageBitmap
|
|
import androidx.compose.ui.graphics.asAndroidBitmap
|
|
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.QueryParameters
|
|
import com.arcgismaps.data.ServiceFeatureTable
|
|
import com.arcgismaps.geometry.GeometryEngine
|
|
import com.arcgismaps.geometry.Point
|
|
import com.arcgismaps.geometry.SpatialReference
|
|
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.symbology.SimpleMarkerSymbol
|
|
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle
|
|
import com.arcgismaps.mapping.view.Graphic
|
|
import com.arcgismaps.mapping.view.GraphicsOverlay
|
|
import com.arcgismaps.mapping.view.LocationDisplay
|
|
import com.arcgismaps.mapping.view.ScreenCoordinate
|
|
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
|
|
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
|
|
import kotlinx.coroutines.launch
|
|
import java.io.ByteArrayOutputStream
|
|
|
|
|
|
class MapViewModel(application: Application) : AndroidViewModel(application) {
|
|
|
|
companion object {
|
|
// Zentrale Definition der Schadenstypen
|
|
val DAMAGE_TYPES = listOf(
|
|
"Straße",
|
|
"Gehweg",
|
|
"Fahrradweg",
|
|
"Beleuchtung",
|
|
"Sonstiges"
|
|
)
|
|
}
|
|
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
|
|
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
|
}
|
|
var selectedOperation by mutableStateOf(FeatureOperationType.DEFAULT)
|
|
var reopenReport by mutableStateOf(false)
|
|
private set
|
|
var showFeatureInfo by mutableStateOf(false)
|
|
private set
|
|
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
|
|
val mapViewProxy = MapViewProxy()
|
|
var reportDraft by mutableStateOf(ReportDraft())
|
|
private set
|
|
lateinit var featureLayer: FeatureLayer
|
|
var snackBarMessage: String by mutableStateOf("")
|
|
lateinit var serviceFeatureTable: ServiceFeatureTable
|
|
var currentDamageType by mutableStateOf("")
|
|
var damageTypeList: List<String> = mutableListOf()
|
|
var locationDisplay: LocationDisplay? = null
|
|
// Create a red circle simple marker symbol.
|
|
val redCircleSymbol = SimpleMarkerSymbol(
|
|
style = SimpleMarkerSymbolStyle.Circle,
|
|
color = com.arcgismaps.Color.red,
|
|
size = 10.0f
|
|
)
|
|
var pointGraphic = Graphic(null, redCircleSymbol)
|
|
val tempOverlay = GraphicsOverlay()
|
|
|
|
init {
|
|
tempOverlay.graphics.add(pointGraphic)
|
|
viewModelScope.launch {
|
|
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/arcgis/rest/services/si_StrassenSchaeden/FeatureServer/0")
|
|
serviceFeatureTable.load().onSuccess {
|
|
val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" }
|
|
val attributeDomain = typeDamageField?.domain as? CodedValueDomain
|
|
attributeDomain?.codedValues?.forEach {
|
|
damageTypeList += it.name
|
|
}
|
|
println("DEBUG: ServiceFeatureTable erfolgreich geladen")
|
|
}.onFailure {
|
|
println("DEBUG: Fehler beim Laden der Tabelle: ${it.message}")
|
|
}
|
|
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
|
|
map.operationalLayers.add(featureLayer)
|
|
}
|
|
}
|
|
|
|
fun pickCurrentLocation() {
|
|
// keine Coroutine nötig, das ist alles sync
|
|
val pos = locationDisplay?.location?.value?.position
|
|
if (pos == null) {
|
|
snackBarMessage = "Kein GPS Signal. Bitte kurz warten oder Standort aktivieren."
|
|
return
|
|
}
|
|
|
|
// pos ist ggf. schon Point, aber wir erzwingen WGS84 (sicher für deinen Feature-Service)
|
|
val pointWgs84 =
|
|
if (pos.spatialReference == SpatialReference.wgs84()) pos
|
|
else GeometryEngine.projectOrNull(pos, SpatialReference.wgs84()) as Point
|
|
|
|
updateReportDraft { copy(point = pointWgs84) }
|
|
pointGraphic.geometry = pointWgs84
|
|
snackBarMessage = "Position aus GPS gesetzt."
|
|
}
|
|
|
|
private suspend fun applyEditsWithPhotos(feature: ArcGISFeature, photos: List<ImageBitmap>) {
|
|
serviceFeatureTable.applyEdits().onSuccess { editResults ->
|
|
val result = editResults.firstOrNull()
|
|
if (result != null && result.error == null) {
|
|
val serverObjectId = result.objectId
|
|
println("DEBUG: Server-Erfolg! Echte ObjectID: $serverObjectId")
|
|
|
|
if (photos.isNotEmpty()) {
|
|
// Fix: erstellen eine Abfrage für die neue ID
|
|
val queryParameters = QueryParameters().apply {
|
|
objectIds.add(serverObjectId)
|
|
}
|
|
|
|
// laden das Feature neu vom Server
|
|
serviceFeatureTable.queryFeatures(queryParameters).onSuccess { queryResult ->
|
|
// Ein FeatureQueryResult ist ein Iterator, nehmen das erste Element
|
|
val fetchedFeature = queryResult.firstOrNull() as? ArcGISFeature
|
|
|
|
if (fetchedFeature != null) {
|
|
addPhotosToFeature(fetchedFeature, photos, serverObjectId)
|
|
} else {
|
|
println("DEBUG: Feature nach Query nicht gefunden")
|
|
snackBarMessage = "Fehler: Feature-ID $serverObjectId nicht gefunden."
|
|
}
|
|
}.onFailure {
|
|
println("DEBUG: Query fehlgeschlagen: ${it.message}")
|
|
snackBarMessage = "Fotos konnten nicht zugeordnet werden."
|
|
}
|
|
} else {
|
|
snackBarMessage = "Erfolgreich gemeldet! ID: $serverObjectId"
|
|
}
|
|
} else {
|
|
println("DEBUG: Server-Fehler bei applyEdits: ${result?.error?.message}")
|
|
snackBarMessage = "Serverfehler: ${result?.error?.message}"
|
|
}
|
|
}.onFailure {
|
|
println("DEBUG: applyEdits total fehlgeschlagen: ${it.message}")
|
|
snackBarMessage = "Senden fehlgeschlagen: ${it.message}"
|
|
}
|
|
}
|
|
|
|
private suspend fun addPhotosToFeature(
|
|
feature: ArcGISFeature,
|
|
photos: List<ImageBitmap>,
|
|
objectId: Long?
|
|
) {
|
|
println("DEBUG: Füge ${photos.size} Fotos hinzu...")
|
|
photos.forEachIndexed { index, imageBitmap ->
|
|
try {
|
|
val byteArray = imageBitmapToByteArray(imageBitmap)
|
|
feature.addAttachment(
|
|
name = "photo_$index.jpg",
|
|
contentType = "image/jpeg",
|
|
data = byteArray
|
|
)
|
|
} catch (e: Exception) {
|
|
println("DEBUG: Attachment-Fehler bei Foto $index: ${e.message}")
|
|
}
|
|
}
|
|
|
|
serviceFeatureTable.updateFeature(feature).onSuccess {
|
|
println("DEBUG: Feature mit Anhängen aktualisiert. Finales applyEdits...")
|
|
serviceFeatureTable.applyEdits().onSuccess {
|
|
snackBarMessage = "Gespeichert mit ${photos.size} Fotos! ID: $objectId"
|
|
}
|
|
}.onFailure {
|
|
println("DEBUG: updateFeature für Anhänge fehlgeschlagen: ${it.message}")
|
|
}
|
|
}
|
|
|
|
private fun imageBitmapToByteArray(imageBitmap: ImageBitmap): ByteArray {
|
|
val stream = ByteArrayOutputStream()
|
|
imageBitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.JPEG, 80, stream)
|
|
return stream.toByteArray()
|
|
}
|
|
|
|
fun onTap(singleTapConfirmedEvent: SingleTapConfirmedEvent) {
|
|
if (featureLayer.loadStatus.value != LoadStatus.Loaded) {
|
|
snackBarMessage = "Layer not loaded!"
|
|
return
|
|
}
|
|
|
|
when (selectedOperation) {
|
|
FeatureOperationType.DEFAULT -> selectFeatureAt(singleTapConfirmedEvent.screenCoordinate)
|
|
FeatureOperationType.DELETE -> deleteFeatureAt(singleTapConfirmedEvent.screenCoordinate)
|
|
FeatureOperationType.UPDATE_ATTRIBUTE -> selectFeatureForAttributeEditAt(singleTapConfirmedEvent.screenCoordinate)
|
|
FeatureOperationType.UPDATE_GEOMETRY -> updateFeatureGeometryAt(singleTapConfirmedEvent.screenCoordinate)
|
|
FeatureOperationType.PICK_REPORT_LOCATION -> pickReportLocation(singleTapConfirmedEvent.screenCoordinate)
|
|
// RATE_FEATURE wird nicht mehr gebraucht - wird im DEFAULT mit behandelt!
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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"]}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun pickReportLocation(screen: ScreenCoordinate) {
|
|
val mapPoint = mapViewProxy.screenToLocationOrNull(screen)
|
|
val p = mapPoint?.let { GeometryEngine.normalizeCentralMeridian(it) as? Point }
|
|
|
|
if (p != null) {
|
|
reportDraft = reportDraft.copy(point = p)
|
|
pointGraphic.geometry = p
|
|
reopenReport = true
|
|
snackBarMessage = "Position gesetzt."
|
|
} else {
|
|
snackBarMessage = "Position konnte nicht gesetzt werden."
|
|
}
|
|
|
|
selectedOperation = FeatureOperationType.DEFAULT
|
|
}
|
|
|
|
|
|
fun startPickReportLocation() {
|
|
selectedOperation = FeatureOperationType.PICK_REPORT_LOCATION
|
|
snackBarMessage = "Tippe auf die Karte, um die Position zu setzen."
|
|
}
|
|
|
|
fun consumeReopenReport() {
|
|
reopenReport = false
|
|
}
|
|
|
|
fun resetDraft() {
|
|
reportDraft = ReportDraft()
|
|
pointGraphic.geometry = null
|
|
selectedOperation = FeatureOperationType.DEFAULT
|
|
}
|
|
|
|
fun updateReportDraft(update: ReportDraft.() -> ReportDraft) {
|
|
reportDraft = reportDraft.update()
|
|
}
|
|
|
|
fun submitDraftToLayer() {
|
|
val draft = reportDraft
|
|
if (!draft.isValid) {
|
|
snackBarMessage = "Bitte Beschreibung, Typ, Position und Fotos setzen."
|
|
return
|
|
}
|
|
|
|
viewModelScope.launch {
|
|
try {
|
|
// 1) Feature lokal erstellen
|
|
val feature = serviceFeatureTable.createFeature().apply {
|
|
geometry = draft.point
|
|
attributes["Beschreibung"] = draft.beschreibung
|
|
attributes["Typ"] = draft.typ
|
|
}
|
|
|
|
// 2) Erst addFeature + applyEdits => Feature existiert am Server (ObjectID)
|
|
serviceFeatureTable.addFeature(feature).onSuccess {
|
|
// applyEditsWithPhotos macht bei dir: applyEdits -> ObjectID holen -> feature neu queryn -> attachments adden
|
|
applyEditsWithPhotos(feature as ArcGISFeature, draft.photos)
|
|
|
|
// Draft/Preview zurücksetzen (am besten nach Erfolg; fürs Erste hier ok)
|
|
resetDraft()
|
|
}.onFailure {
|
|
snackBarMessage = "Fehler beim Hinzufügen: ${it.message}"
|
|
}
|
|
|
|
} catch (e: Exception) {
|
|
snackBarMessage = "Fehler: ${e.message}"
|
|
}
|
|
}
|
|
}
|
|
|
|
fun selectFeatureAt(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 {
|
|
selectedFeature = it.also {
|
|
featureLayer.selectFeature(it)
|
|
showFeatureInfo = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun closeFeatureInfo() {
|
|
showFeatureInfo = false
|
|
selectedFeature = null
|
|
featureLayer.clearSelection()
|
|
}
|
|
}
|
|
|
|
enum class FeatureOperationType(val operationName: String, val instruction: String) {
|
|
DEFAULT("Default", ""),
|
|
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."),
|
|
PICK_REPORT_LOCATION("Pick report location", "Tippe auf die Karte, um die Position zu setzen."),
|
|
}
|
|
|
|
data class ReportDraft(
|
|
val beschreibung: String = "",
|
|
val typ: String = "Schadenstyp wählen...",
|
|
val photos: List<ImageBitmap> = emptyList(),
|
|
val point: Point? = null
|
|
) {
|
|
val isValid: Boolean
|
|
get() =
|
|
beschreibung.isNotBlank() &&
|
|
typ != "Schadenstyp wählen..." &&
|
|
point != null &&
|
|
photos.isNotEmpty()
|
|
} |