- Code bereinigt

- MapView in MapSegment ausgelagert
- Punkt kann jetzt manuell gesetzt werden
- Eingaben werden erstmal im ReportDraft gespeichert
This commit is contained in:
2026-01-18 16:49:03 +01:00
parent 97d86523ab
commit 30d5a17e6e
4 changed files with 455 additions and 133 deletions

View File

@@ -2,6 +2,7 @@ package com.example.snapandsolve
import MapViewModel
import android.Manifest
import android.R.attr.enabled
import android.app.Application
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -17,6 +18,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddLocation
import androidx.compose.material.icons.filled.AddLocationAlt
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Menu
@@ -25,6 +28,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -44,10 +48,30 @@ import kotlinx.coroutines.Dispatchers
@Composable
fun MainScreen(modifier: Modifier = Modifier, application: Application) {
var showReport by rememberSaveable { mutableStateOf(false) }
var sliderOpen by remember { mutableStateOf(false) }
var sliderOpen by rememberSaveable { mutableStateOf(false) }
val mapViewModel = remember { MapViewModel(application) }
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
// test
fun openReport() {
showReport = true
sliderOpen = false
}
// test
fun closeReport() {
showReport = false
}
LaunchedEffect(mapViewModel.reopenReport) {
if (mapViewModel.reopenReport) {
showReport = true
mapViewModel.consumeReopenReport()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { AppTopBar() },
@@ -64,8 +88,7 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
floatingActionButton = {
LargeFloatingActionButton(
onClick = {
showReport = true
sliderOpen = false
openReport()
},
modifier = Modifier.offset(y = 64.dp),
containerColor = ButtonColor
@@ -77,11 +100,11 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
) { innerPadding ->
ContentScreen(
modifier = Modifier.padding(innerPadding),
mapViewModel,
albumViewModel,
mapViewModel = mapViewModel,
albumViewModel = albumViewModel,
showReport = showReport,
sliderOpen = sliderOpen,
onDismissReport = { showReport = false }
onDismissReport = ::closeReport
)
}
}
@@ -115,7 +138,9 @@ fun ContentScreen(
)
onDismissReport()
},
viewModel = albumViewModel
onClose = onDismissReport,
viewModel = albumViewModel,
mapViewModel = mapViewModel
)
}
@@ -166,16 +191,21 @@ fun AppTopBar(
fun ReportOverlay(
onCancel: () -> Unit,
onAdd: (beschreibung: String, typ: String) -> Unit,
viewModel: AlbumViewModel
onClose: () -> Unit,
viewModel: AlbumViewModel,
mapViewModel: MapViewModel
) {
val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState()
val currentContext = LocalContext.current
// State für Beschreibung und Typ
var beschreibung by remember { mutableStateOf("") }
var selectedTyp by remember { mutableStateOf("Schadenstyp wählen...") }
val hasPoint = mapViewModel.reportDraft.point != null
var dropdownExpanded by remember { mutableStateOf(false) }
LaunchedEffect(viewState.selectedPictures) {
mapViewModel.updateReportDraft {
copy(photos = viewState.selectedPictures)
}
}
// Launcher für Bildauswahl aus Galerie
val pickImageFromAlbumLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20)
@@ -229,10 +259,17 @@ fun ReportOverlay(
.background(Color.Black.copy(alpha = 0.25f)),
contentAlignment = Alignment.Center
) {
BoxWithConstraints {
val verticalMargin = 50.dp // <-- dein gewünschter Mindestabstand oben + unten
val maxCardHeight = maxHeight - verticalMargin * 2
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.heightIn(min = 400.dp),
.heightIn(
min = 400.dp,
max = maxCardHeight
),
shape = RoundedCornerShape(24.dp),
colors = CardColors(
containerColor = WidgetColor,
@@ -256,17 +293,27 @@ fun ReportOverlay(
onClick = { dropdownExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("Typ: $selectedTyp")
Text("Typ: ${mapViewModel.reportDraft.typ}")
}
DropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
listOf("Straße", "Gehweg", "Fahrradweg", "Beleuchtung","Sonstiges").forEach { typ ->
listOf(
"Straße",
"Gehweg",
"Fahrradweg",
"Beleuchtung",
"Sonstiges"
).forEach { typ ->
DropdownMenuItem(
text = { Text(typ) },
text = {
Text(typ)
},
onClick = {
selectedTyp = typ
mapViewModel.updateReportDraft {
copy(typ = typ)
}
dropdownExpanded = false
}
)
@@ -299,8 +346,12 @@ fun ReportOverlay(
// Textfeld für Beschreibung
TextField(
value = beschreibung,
onValueChange = { beschreibung = it },
value = mapViewModel.reportDraft.beschreibung,
onValueChange = {
mapViewModel.updateReportDraft {
copy(beschreibung = it)
}
},
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
@@ -335,13 +386,38 @@ fun ReportOverlay(
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
onClick = {
},
) {
Text("Position aus Standort")
}
Button(
onClick = {
mapViewModel.startPickReportLocation()
onClose()
},
) {
Text(if (hasPoint) "Position neu setzen" else "Position manuell setzen")
}
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedButton(
onClick = onCancel,
onClick = {
mapViewModel.resetDraft()
viewModel.clearSelection()
onCancel()
},
colors = ButtonColors(
containerColor = ButtonColor,
contentColor = Color.White,
@@ -352,8 +428,12 @@ fun ReportOverlay(
Text("Abbrechen", color = Color.Black)
}
Button(
onClick = { onAdd(beschreibung, selectedTyp) },
enabled = beschreibung.isNotBlank()
onClick = {
mapViewModel.submitDraftToLayer()
viewModel.clearSelection()
onCancel()
},
enabled = mapViewModel.reportDraft.isValid
) {
Text("Hinzufügen")
}
@@ -362,3 +442,4 @@ fun ReportOverlay(
}
}
}
}

View File

@@ -20,7 +20,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.arcgismaps.ApiKey
import com.arcgismaps.ArcGISEnvironment
import com.arcgismaps.Color
import com.arcgismaps.location.LocationDisplayAutoPanMode
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle
import com.arcgismaps.mapping.view.GraphicsOverlay
import com.arcgismaps.toolkit.geoviewcompose.MapView
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
import kotlinx.coroutines.launch
@@ -68,7 +72,8 @@ fun MapSegment(
arcGISMap = mapViewModel.map,
locationDisplay = locationDisplay,
mapViewProxy = mapViewModel.mapViewProxy,
onSingleTapConfirmed = {}//mapViewModel::onTap,
onSingleTapConfirmed = mapViewModel::onTap,
graphicsOverlays = listOf(mapViewModel.tempOverlay)
) {
/* TODO */
}

View File

@@ -1,14 +1,20 @@
import android.app.Application
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
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
@@ -17,25 +23,46 @@ 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.symbology.SimpleRenderer
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) {
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
}
var selectedOperation by mutableStateOf(FeatureOperationType.DEFAULT)
private set
var reopenReport 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 {
viewModelScope.launch {
@@ -113,7 +140,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
if (photos.isNotEmpty()) {
// Fix: erstellen eine Abfrage für die neue ID
val queryParameters = com.arcgismaps.data.QueryParameters().apply {
val queryParameters = QueryParameters().apply {
objectIds.add(serverObjectId)
}
@@ -176,7 +203,213 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
private fun imageBitmapToByteArray(imageBitmap: ImageBitmap): ByteArray {
val stream = ByteArrayOutputStream()
imageBitmap.asAndroidBitmap().compress(android.graphics.Bitmap.CompressFormat.JPEG, 80, stream)
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 -> selectFeatureForAttributeEditAt(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)
else -> {}
}
}
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)
}
}
}
}
}
// 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"]}"
}
}
}
}
}
}
}
}
}
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)
tempOverlay.graphics.clear()
pointGraphic.geometry = p
tempOverlay.graphics.add(pointGraphic)
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}"
}
}
}
}
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()
}

View File

@@ -116,4 +116,7 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
}
}
// endregion
fun clearSelection() {
_albumViewState.value = _albumViewState.value.copy(selectedPictures = emptyList())
}
}