- Schadensliste wurde implementiert

This commit is contained in:
2026-01-26 13:54:14 +01:00
parent 05426b687c
commit fbf677c23a
8 changed files with 604 additions and 4 deletions

6
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
</component>
</project>

View File

@@ -4,14 +4,22 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-12-21T13:16:32.335138100Z"> <DropdownSelection timestamp="2026-01-25T17:20:21.835673100Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
<DialogSelection /> <DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@@ -133,6 +133,8 @@ fun ContentScreen(
// NEU: State für Filter-Dialog // NEU: State für Filter-Dialog
var showFilterDialog by remember { mutableStateOf(false) } var showFilterDialog by remember { mutableStateOf(false) }
var showDamageList by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
// Map // Map
MapSegment( MapSegment(
@@ -140,6 +142,18 @@ fun ContentScreen(
mapViewModel = mapViewModel mapViewModel = mapViewModel
) )
// Dialog
if (showDamageList) {
DamageListDialog(
onDismiss = { showDamageList = false },
onDamageClick = { feature ->
mapViewModel.selectedFeature = feature
mapViewModel.showFeatureInfo = true
},
mapViewModel = mapViewModel
)
}
// Report Overlay // Report Overlay
if (showReport) { if (showReport) {
ReportOverlay( ReportOverlay(
@@ -196,7 +210,7 @@ fun ContentScreen(
SliderMenuItem( SliderMenuItem(
text = "Schadensliste", text = "Schadensliste",
icon = Icons.Default.FormatListNumbered, icon = Icons.Default.FormatListNumbered,
onClick = { /* TODO */ } onClick = { showDamageList = true }
) )
} }
} }

View File

@@ -51,7 +51,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
var reopenReport by mutableStateOf(false) var reopenReport by mutableStateOf(false)
private set private set
var showFeatureInfo by mutableStateOf(false) var showFeatureInfo by mutableStateOf(false)
private set public set
var selectedFeature: ArcGISFeature? by mutableStateOf(null) var selectedFeature: ArcGISFeature? by mutableStateOf(null)
val mapViewProxy = MapViewProxy() val mapViewProxy = MapViewProxy()
var reportDraft by mutableStateOf(ReportDraft()) var reportDraft by mutableStateOf(ReportDraft())

View File

@@ -0,0 +1,551 @@
package com.example.snapandsolve.ui.theme
import MapViewModel
import android.content.Context
import android.graphics.BitmapFactory
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.data.QueryParameters
import com.arcgismaps.geometry.Point
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Data class für einen Schaden mit Entfernung und Foto
*/
data class DamageWithDistance(
val feature: ArcGISFeature,
val distanceInMeters: Double,
val typ: String,
val beschreibung: String,
val objectId: Long,
val rating: Int,
val photo: ImageBitmap? = null
)
/**
* Berechnet die Entfernung zwischen zwei Koordinaten mit Haversine-Formel
*/
fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // Erdradius in Metern
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
/**
* Dialog für Schadensliste mit Entfernungsfilter
*/
@Composable
fun DamageListDialog(
onDismiss: () -> Unit,
onDamageClick: (ArcGISFeature) -> Unit,
mapViewModel: MapViewModel
) {
val context = androidx.compose.ui.platform.LocalContext.current
var damages by remember { mutableStateOf<List<DamageWithDistance>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var maxDistance by remember { mutableStateOf(1000f) }
var userLocation by remember { mutableStateOf<Point?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(maxDistance) {
isLoading = true
errorMessage = null
val location = mapViewModel.locationDisplay?.location?.value?.position
if (location == null) {
errorMessage = "Standort nicht verfügbar. Bitte GPS aktivieren."
isLoading = false
return@LaunchedEffect
}
userLocation = location
val result = mapViewModel.loadDamagesNearby(location, maxDistance.toDouble(), context)
damages = result
isLoading = false
}
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Schäden in der Nähe",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Schließen")
}
}
Divider(modifier = Modifier.padding(vertical = 12.dp))
// Entfernungs-Slider
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.MyLocation,
contentDescription = "Umkreis",
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Umkreis: ${if (maxDistance >= 1000) "${(maxDistance / 1000).roundToInt()} km" else "${maxDistance.toInt()} m"}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = maxDistance,
onValueChange = { maxDistance = it },
valueRange = 100f..5000f,
steps = 48,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("100 m", style = MaterialTheme.typography.bodySmall)
Text("5 km", style = MaterialTheme.typography.bodySmall)
}
}
Spacer(modifier = Modifier.height(12.dp))
if (!isLoading && errorMessage == null) {
Text(
text = "${damages.size} Schäden gefunden",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Content
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Lade Schäden...")
}
}
}
errorMessage != null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = errorMessage!!,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
}
}
}
damages.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "🔍",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Keine Schäden im Umkreis",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(damages) { damage ->
DamageListItemWithPhoto(
damage = damage,
onClick = {
onDamageClick(damage.feature)
onDismiss()
}
)
}
}
}
}
}
}
}
}
/**
* Einzelnes Schadens-Item in der Liste MIT FOTO
*/
@Composable
fun DamageListItemWithPhoto(
damage: DamageWithDistance,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
// LINKS: Foto
Box(
modifier = Modifier
.width(100.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
) {
if (damage.photo != null) {
Image(
bitmap = damage.photo,
contentDescription = "Schadensfoto",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Text("📷", style = MaterialTheme.typography.headlineLarge)
}
}
}
// RECHTS: Info
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Typ mit Emoji
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = getEmojiForType(damage.typ),
style = MaterialTheme.typography.titleLarge
)
Text(
text = damage.typ,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// Beschreibung
Text(
text = damage.beschreibung.take(40) + if (damage.beschreibung.length > 40) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
// Info-Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Text("📍", style = MaterialTheme.typography.labelSmall)
Text(
text = formatDistance(damage.distanceInMeters),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
if (damage.rating > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Text("👥", style = MaterialTheme.typography.labelSmall)
Text(
text = "${damage.rating}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
/**
* Extension-Funktion für MapViewModel
* Lädt alle Schäden im Umkreis
*/
suspend fun MapViewModel.loadDamagesNearby(
userLocation: Point,
maxDistanceMeters: Double,
context: Context
): List<DamageWithDistance> {
return withContext(Dispatchers.IO) {
try {
println("DEBUG 1: Lade Schäden im Umkreis von ${maxDistanceMeters}m")
println("DEBUG 1.1: serviceFeatureTable = $serviceFeatureTable")
val queryParams = QueryParameters().apply {
whereClause = "1=1"
}
println("DEBUG 2: QueryParameters erstellt")
val queryResult = serviceFeatureTable?.queryFeatures(queryParams)?.getOrNull()
println("DEBUG 3: queryResult = $queryResult")
if (queryResult == null) {
println("DEBUG ERROR: queryResult ist NULL!")
return@withContext emptyList()
}
val resultList = queryResult.toList()
println("DEBUG 3.1: resultList.size = ${resultList.size}")
if (resultList.isEmpty()) {
println("DEBUG: queryResult ist LEER (0 Features)")
return@withContext emptyList()
}
val damagesWithDistance = mutableListOf<DamageWithDistance>()
var processedCount = 0
var addedCount = 0
queryResult.forEach { geoElement ->
processedCount++
println("DEBUG 4.$processedCount: Verarbeite Feature...")
val feature = geoElement as? ArcGISFeature
if (feature == null) {
println("DEBUG 4.$processedCount: Feature ist NULL, überspringe")
return@forEach
}
feature.load().getOrNull()
val featureGeometry = feature.geometry as? Point
if (featureGeometry == null) {
println("DEBUG 4.$processedCount: Geometrie ist NULL")
return@forEach
}
// Transformiere Feature-Koordinaten in die gleiche räumliche Referenz wie userLocation
val targetSpatialRef = userLocation.spatialReference
if (targetSpatialRef == null) {
println("DEBUG 4.$processedCount: User SpatialReference ist NULL")
return@forEach
}
val transformedGeometry = com.arcgismaps.geometry.GeometryEngine.projectOrNull(
featureGeometry,
targetSpatialRef
) as? Point
if (transformedGeometry == null) {
println("DEBUG 4.$processedCount: Transformation fehlgeschlagen")
return@forEach
}
// Entfernung berechnen mit Haversine-Formel
val distance = calculateDistance(
userLocation.y,
userLocation.x,
transformedGeometry.y,
transformedGeometry.x
)
println("DEBUG 4.$processedCount: Distance = $distance, max = $maxDistanceMeters")
if (distance <= maxDistanceMeters) {
addedCount++
val typ = feature.attributes["Typ"]?.toString() ?: "Unbekannt"
val beschreibung = feature.attributes["Beschreibung"]?.toString() ?: "Keine Beschreibung"
val objectId = (feature.attributes["OBJECTID"] as? Number)?.toLong() ?: 0L
val rating = (feature.attributes["communitycounter"] as? Number)?.toInt() ?: 0
// Foto laden (async)
val photo = try {
val attachments = feature.fetchAttachments().getOrNull()
val firstAttachment = attachments?.firstOrNull()
if (firstAttachment != null) {
val data = firstAttachment.fetchData().getOrNull()
if (data != null) {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
bitmap?.asImageBitmap()
} else null
} else null
} catch (e: Exception) {
null
}
damagesWithDistance.add(
DamageWithDistance(
feature = feature,
distanceInMeters = distance,
typ = typ,
beschreibung = beschreibung,
objectId = objectId,
rating = rating,
photo = photo
)
)
}
}
val sorted = damagesWithDistance.sortedBy { it.distanceInMeters }
println("DEBUG: $processedCount Features verarbeitet, $addedCount hinzugefügt")
sorted
} catch (e: Exception) {
println("DEBUG ERROR: ${e.message}")
e.printStackTrace()
withContext(Dispatchers.Main) {
Toast.makeText(context, "Fehler: ${e.message}", Toast.LENGTH_LONG).show()
}
emptyList()
}
}
}
fun formatDistance(meters: Double): String {
return if (meters < 1000) {
"${meters.roundToInt()} m"
} else {
"${"%.1f".format(meters / 1000)} km"
}
}
fun getEmojiForType(typ: String): String {
return when (typ) {
"Straße" -> "🛣️"
"Gehweg" -> "🚶"
"Fahrradweg" -> "🚴"
"Beleuchtung" -> "💡"
"Sonstiges" -> "📍"
else -> ""
}
}

0
docs/DamageListSystem.md Normal file
View File