diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..6bbe2ae
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 6bc5f1a..ac0b642 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,14 +4,22 @@
-
+
-
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/snapandsolve/MainScreen.kt b/app/src/main/java/com/example/snapandsolve/MainScreen.kt
index 2ed594a..544eafc 100644
--- a/app/src/main/java/com/example/snapandsolve/MainScreen.kt
+++ b/app/src/main/java/com/example/snapandsolve/MainScreen.kt
@@ -133,6 +133,8 @@ fun ContentScreen(
// NEU: State für Filter-Dialog
var showFilterDialog by remember { mutableStateOf(false) }
+ var showDamageList by remember { mutableStateOf(false) }
+
Box(modifier = modifier.fillMaxSize()) {
// Map
MapSegment(
@@ -140,6 +142,18 @@ fun ContentScreen(
mapViewModel = mapViewModel
)
+ // Dialog
+ if (showDamageList) {
+ DamageListDialog(
+ onDismiss = { showDamageList = false },
+ onDamageClick = { feature ->
+ mapViewModel.selectedFeature = feature
+ mapViewModel.showFeatureInfo = true
+ },
+ mapViewModel = mapViewModel
+ )
+ }
+
// Report Overlay
if (showReport) {
ReportOverlay(
@@ -196,7 +210,7 @@ fun ContentScreen(
SliderMenuItem(
text = "Schadensliste",
icon = Icons.Default.FormatListNumbered,
- onClick = { /* TODO */ }
+ onClick = { showDamageList = true }
)
}
}
diff --git a/app/src/main/java/com/example/snapandsolve/MapViewModel.kt b/app/src/main/java/com/example/snapandsolve/MapViewModel.kt
index 8f1fb01..337e0c5 100644
--- a/app/src/main/java/com/example/snapandsolve/MapViewModel.kt
+++ b/app/src/main/java/com/example/snapandsolve/MapViewModel.kt
@@ -51,7 +51,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
var reopenReport by mutableStateOf(false)
private set
var showFeatureInfo by mutableStateOf(false)
- private set
+ public set
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
val mapViewProxy = MapViewProxy()
var reportDraft by mutableStateOf(ReportDraft())
diff --git a/app/src/main/java/com/example/snapandsolve/ui/theme/DamageListSystem.kt b/app/src/main/java/com/example/snapandsolve/ui/theme/DamageListSystem.kt
new file mode 100644
index 0000000..2a01c54
--- /dev/null
+++ b/app/src/main/java/com/example/snapandsolve/ui/theme/DamageListSystem.kt
@@ -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>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+ var maxDistance by remember { mutableStateOf(1000f) }
+ var userLocation by remember { mutableStateOf(null) }
+ var errorMessage by remember { mutableStateOf(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 {
+ 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()
+ 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 -> "•"
+ }
+}
\ No newline at end of file
diff --git a/docs/DamageListSystem.md b/docs/DamageListSystem.md
new file mode 100644
index 0000000..e69de29