From fbf677c23a39c27e559148728c7b647e01583d02 Mon Sep 17 00:00:00 2001 From: si2503 Date: Mon, 26 Jan 2026 13:54:14 +0100 Subject: [PATCH] - Schadensliste wurde implementiert --- .idea/appInsightsSettings.xml | 6 + .idea/deploymentTargetSelector.xml | 12 +- .idea/deviceManager.xml | 13 + .idea/markdown.xml | 8 + .../com/example/snapandsolve/MainScreen.kt | 16 +- .../com/example/snapandsolve/MapViewModel.kt | 2 +- .../snapandsolve/ui/theme/DamageListSystem.kt | 551 ++++++++++++++++++ docs/DamageListSystem.md | 0 8 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/markdown.xml create mode 100644 app/src/main/java/com/example/snapandsolve/ui/theme/DamageListSystem.kt create mode 100644 docs/DamageListSystem.md 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