From 7781551e02fe0b0baa4cdc0543bb799b0f0bef62 Mon Sep 17 00:00:00 2001 From: fr2651 Date: Fri, 6 Feb 2026 15:34:39 +0100 Subject: [PATCH] =?UTF-8?q?status=20hinzugef=C3=BCgt=20regelbasiertes=20st?= =?UTF-8?q?yling=20architektur=20=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../com/example/snapandsolve/MainScreen.kt | 254 +------------ .../com/example/snapandsolve/MapViewModel.kt | 23 +- .../ui/theme/DamageFilterSystem.kt | 85 ++--- .../snapandsolve/ui/theme/DamageListSystem.kt | 12 +- .../ui/theme/composable/DialogContainer.kt | 339 ++++++++++++++++++ .../ui/theme/composable/Legende.kt | 64 ++++ .../ui/theme/{ => composable}/SideSlider.kt | 3 +- .../snapandsolve/view/CloseDamageDialog.kt | 60 ++++ .../example/snapandsolve/view/ReportDialog.kt | 253 +++++++++++++ .../snapandsolve/view/StatusSymbolRenderer.kt | 86 +++++ .../viewmodel/NearbyDamageCheck.kt | 79 ++++ gradle/libs.versions.toml | 2 + 13 files changed, 960 insertions(+), 301 deletions(-) create mode 100644 app/src/main/java/com/example/snapandsolve/ui/theme/composable/DialogContainer.kt create mode 100644 app/src/main/java/com/example/snapandsolve/ui/theme/composable/Legende.kt rename app/src/main/java/com/example/snapandsolve/ui/theme/{ => composable}/SideSlider.kt (95%) create mode 100644 app/src/main/java/com/example/snapandsolve/view/CloseDamageDialog.kt create mode 100644 app/src/main/java/com/example/snapandsolve/view/ReportDialog.kt create mode 100644 app/src/main/java/com/example/snapandsolve/view/StatusSymbolRenderer.kt create mode 100644 app/src/main/java/com/example/snapandsolve/viewmodel/NearbyDamageCheck.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe07f3a..c352537 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.material3) + implementation(libs.androidx.compose.runtime) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/example/snapandsolve/MainScreen.kt b/app/src/main/java/com/example/snapandsolve/MainScreen.kt index 0cea3f8..0c89d20 100644 --- a/app/src/main/java/com/example/snapandsolve/MainScreen.kt +++ b/app/src/main/java/com/example/snapandsolve/MainScreen.kt @@ -3,54 +3,26 @@ package com.example.snapandsolve import DamageFilterDialog import DamageListDialog import MapViewModel -import android.Manifest -import android.R.attr.enabled import android.app.Application -import android.graphics.BitmapFactory -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.rememberScrollState -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 -import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material3.* 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.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import applyDamageFilter -import com.arcgismaps.data.ArcGISFeature -// Hier holen wir die ArcGIS Klassen -import com.arcgismaps.mapping.ArcGISMap -import com.arcgismaps.mapping.BasemapStyle -import com.arcgismaps.mapping.Viewpoint -import com.arcgismaps.toolkit.geoviewcompose.MapView -// Hier deine eigenen Klassen (Pfade prüfen!) import com.example.snapandsolve.camera.AlbumViewModel -import com.example.snapandsolve.camera.AlbumViewState -import com.example.snapandsolve.camera.Intent import com.example.snapandsolve.ui.theme.* +import com.example.snapandsolve.ui.theme.composable.SideSlider +import com.example.snapandsolve.ui.theme.composable.SliderMenuItem +import com.example.snapandsolve.view.ReportDialog import getActiveFilters import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -60,17 +32,14 @@ import kotlinx.coroutines.launch fun MainScreen(modifier: Modifier = Modifier, application: Application) { var showReport by rememberSaveable { 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 } @@ -160,7 +129,7 @@ fun ContentScreen( // Report Overlay if (showReport) { - ReportOverlay( + ReportDialog( onCancel = onDismissReport, onClose = onDismissReport, viewModel = albumViewModel, @@ -236,219 +205,7 @@ fun AppTopBar( ) } -@Composable -fun ReportOverlay( - onCancel: () -> Unit, - onClose: () -> Unit, - viewModel: AlbumViewModel, - mapViewModel: MapViewModel -) { - val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState() - val currentContext = LocalContext.current - val hasPoint = mapViewModel.reportDraft.point != null - var dropdownExpanded by remember { mutableStateOf(false) } - - LaunchedEffect(viewState.selectedPictures) { - mapViewModel.updateReportDraft { copy(photos = viewState.selectedPictures) } - } - - val pickImageFromAlbumLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(20) - ) { urls -> - viewModel.onReceive(Intent.OnFinishPickingImagesWith(currentContext, urls)) - } - - val cameraLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.TakePicture() - ) { isImageSaved -> - if (isImageSaved) viewModel.onReceive(Intent.OnImageSavedWith(currentContext)) - else viewModel.onReceive(Intent.OnImageSavingCanceled) - } - - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { permissionGranted -> - if (permissionGranted) viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext)) - else viewModel.onReceive(Intent.OnPermissionDenied) - } - - fun startCamera() { - val hasPermission = currentContext.checkSelfPermission(Manifest.permission.CAMERA) == - android.content.pm.PackageManager.PERMISSION_GRANTED - if (hasPermission) viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext)) - else permissionLauncher.launch(Manifest.permission.CAMERA) - } - - LaunchedEffect(viewState.tempFileUrl) { - viewState.tempFileUrl?.let { cameraLauncher.launch(it) } - } - - OverlayShell( - title = "Neue Meldung", - footer = { - OutlinedButton( - onClick = { - mapViewModel.resetDraft() - viewModel.clearSelection() - onCancel() - } - ) { Text("Abbrechen", color = Color.Black) } - - Button( - onClick = { - mapViewModel.submitDraftToLayer() - viewModel.clearSelection() - onCancel() - }, - enabled = mapViewModel.reportDraft.isValid - ) { Text("Hinzufügen") } - } - ) { - Text("Schadensbeschreibung:", color = Color.Black) - - // Typ Dropdown - Box { - OutlinedButton( - onClick = { dropdownExpanded = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Typ: ${mapViewModel.reportDraft.typ}") - } - DropdownMenu( - expanded = dropdownExpanded, - onDismissRequest = { dropdownExpanded = false } - ) { - MapViewModel.DAMAGE_TYPES.forEach { typ -> // <-- Nutzt zentrale Liste - DropdownMenuItem( - text = { Text(typ) }, - onClick = { - mapViewModel.updateReportDraft { copy(typ = typ) } - dropdownExpanded = false - } - ) - } - } - } - - // Kamera Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button(onClick = { startCamera() }, modifier = Modifier.weight(1f)) { - Text("Foto aufnehmen") - } - Button( - onClick = { - pickImageFromAlbumLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - modifier = Modifier.weight(1f) - ) { - Text("Aus Galerie") - } - } - - // Beschreibung - TextField( - value = mapViewModel.reportDraft.beschreibung, - onValueChange = { text -> mapViewModel.updateReportDraft { copy(beschreibung = text) } }, - modifier = Modifier.fillMaxWidth().height(220.dp), - placeholder = { Text("Beschreibung eingeben...") }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White, - unfocusedContainerColor = Color.White - ) - ) - - // Bilder Grid (body ist scrollbar, grid selbst nicht) - if (viewState.selectedPictures.isNotEmpty()) { - Text( - text = "Ausgewählte Bilder (${viewState.selectedPictures.size})", - color = Color.Black - ) - LazyVerticalGrid( - columns = GridCells.Fixed(3), - userScrollEnabled = false, - modifier = Modifier.fillMaxWidth().heightIn(0.dp, 200.dp) - ) { - itemsIndexed(viewState.selectedPictures) { index, picture -> - Image( - modifier = Modifier.padding(4.dp), - bitmap = picture, - contentDescription = "Bild ${index + 1}", - contentScale = ContentScale.Crop - ) - } - } - } - - // Position Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button( - onClick = { - mapViewModel.pickCurrentLocation() - } - ) { - Text(if (hasPoint) "Neue Position aus Standort" else "Position aus Standort") - } - Button( - onClick = { - mapViewModel.startPickReportLocation() - onClose() - } - ) { - Text(if (hasPoint) "Position neu setzen" else "Position manuell setzen") - } - } - } -} - -@Composable -fun FeatureInfoOverlay( - feature: ArcGISFeature, - onClose: () -> Unit -) { - val typ = feature.attributes["Typ"].toString() - val beschreibung = feature.attributes["Beschreibung"].toString() - val id = feature.attributes["OBJECTID"].toString() - var image by remember { mutableStateOf(null) } - - LaunchedEffect(feature) { - image = loadFirstAttachmentBitmap(feature) - } - - - OverlayShell( - title = "Meldung $id", - footer = { - OutlinedButton( - onClick = { - onClose() - } - ) { Text("Schließen", color = Color.Black) } - } - ) { - image?.let { - Image( - bitmap = it, - contentDescription = "Feature Bild", - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentScale = ContentScale.Crop - ) - } - Text("Typ: $typ", color = Color.Black) - Text("Beschreibung:", color = Color.Black) - Text(beschreibung, color = Color.Black) - } -} - +/* suspend fun loadFirstAttachmentBitmap( feature: ArcGISFeature ): ImageBitmap? { @@ -467,4 +224,5 @@ suspend fun loadFirstAttachmentBitmap( val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size) return bitmap.asImageBitmap() } + */ diff --git a/app/src/main/java/com/example/snapandsolve/MapViewModel.kt b/app/src/main/java/com/example/snapandsolve/MapViewModel.kt index 960d700..9f0c47d 100644 --- a/app/src/main/java/com/example/snapandsolve/MapViewModel.kt +++ b/app/src/main/java/com/example/snapandsolve/MapViewModel.kt @@ -28,10 +28,13 @@ 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 com.example.snapandsolve.view.createTypStatusRenderer +import com.example.snapandsolve.viewmodel.findNearbyDamageOfSameType import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream + class MapViewModel(application: Application) : AndroidViewModel(application) { companion object { @@ -47,6 +50,8 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply { initialViewpoint = Viewpoint(53.14, 8.20, 20000.0) } + var duplicateDamages by mutableStateOf>(emptyList()) + private set var selectedOperation by mutableStateOf(FeatureOperationType.DEFAULT) var reopenReport by mutableStateOf(false) private set @@ -74,7 +79,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { init { tempOverlay.graphics.add(pointGraphic) viewModelScope.launch { - serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/arcgis/rest/services/si_StrassenSchaeden/FeatureServer/0") + serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/251120_StrassenSchaeden/FeatureServer/0") serviceFeatureTable.load().onSuccess { val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" } val attributeDomain = typeDamageField?.domain as? CodedValueDomain @@ -94,6 +99,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { println("DEBUG: Fehler beim Laden der Tabelle: ${it.message}") } featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable) + featureLayer.renderer = createTypStatusRenderer() map.operationalLayers.add(featureLayer) // ===== DEBUG: Felder nach dem Hinzufügen zur Map ===== @@ -359,6 +365,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { geometry = draft.point attributes["Beschreibung"] = draft.beschreibung attributes["Typ"] = draft.typ + attributes["status"] = draft.status } // 2) Erst addFeature + applyEdits => Feature existiert am Server (ObjectID) @@ -405,6 +412,17 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { selectedFeature = null featureLayer.clearSelection() } + + suspend fun isDuplicateNearby(radiusMeters: Double): Boolean { + val p = reportDraft.point ?: return false + val t = reportDraft.typ + duplicateDamages = findNearbyDamageOfSameType(serviceFeatureTable, p, t, radiusMeters) + return duplicateDamages.isNotEmpty() + } + + fun clearDuplicateDamages() { + duplicateDamages = emptyList() + } } enum class FeatureOperationType(val operationName: String, val instruction: String) { @@ -419,7 +437,8 @@ data class ReportDraft( val beschreibung: String = "", val typ: String = "Schadenstyp wählen...", val photos: List = emptyList(), - val point: Point? = null + val point: Point? = null, + val status: String = "neu" ) { val isValid: Boolean get() = diff --git a/app/src/main/java/com/example/snapandsolve/ui/theme/DamageFilterSystem.kt b/app/src/main/java/com/example/snapandsolve/ui/theme/DamageFilterSystem.kt index 3b07d29..c32e15d 100644 --- a/app/src/main/java/com/example/snapandsolve/ui/theme/DamageFilterSystem.kt +++ b/app/src/main/java/com/example/snapandsolve/ui/theme/DamageFilterSystem.kt @@ -17,6 +17,49 @@ import kotlinx.coroutines.withContext import java.time.LocalDate import java.time.format.DateTimeFormatter + +/** + * Checkbox-Item für einen Filter + */ +@Composable +fun FilterCheckboxItem( + label: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isChecked, + onCheckedChange = onCheckedChange + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + Text( + text = when (label) { + "Straße" -> "🛣️" + "Gehweg" -> "🚶" + "Fahrradweg" -> "🚴" + "Beleuchtung" -> "💡" + "Sonstiges" -> "📍" + else -> "•" + }, + style = MaterialTheme.typography.headlineSmall + ) + } +} + /** * Dialog für Schaden-Filter * Ermöglicht das Filtern von Features nach Typ UND Datum (unabhängig voneinander) @@ -268,48 +311,6 @@ fun DamageFilterDialog( } } -/** - * Checkbox-Item für einen Filter - */ -@Composable -fun FilterCheckboxItem( - label: String, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = isChecked, - onCheckedChange = onCheckedChange - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - - Text( - text = when (label) { - "Straße" -> "🛣️" - "Gehweg" -> "🚶" - "Fahrradweg" -> "🚴" - "Beleuchtung" -> "💡" - "Sonstiges" -> "📍" - else -> "•" - }, - style = MaterialTheme.typography.headlineSmall - ) - } -} - /** * Extension-Funktion für MapViewModel * Wendet Filter auf den FeatureLayer an (Typ + Datum - UNABHÄNGIG) 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 index 79390e8..cb7f69e 100644 --- a/app/src/main/java/com/example/snapandsolve/ui/theme/DamageListSystem.kt +++ b/app/src/main/java/com/example/snapandsolve/ui/theme/DamageListSystem.kt @@ -1,4 +1,3 @@ -import MapViewModel import android.content.Context import android.graphics.BitmapFactory import android.widget.Toast @@ -11,8 +10,6 @@ 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.material.icons.filled.TrendingUp import androidx.compose.material3.* @@ -20,7 +17,6 @@ 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 @@ -44,7 +40,7 @@ import kotlin.math.sqrt */ data class DamageWithDistance( val feature: ArcGISFeature, - val distanceInMeters: Double, + val distanceInMeters: Double?, val typ: String, val beschreibung: String, val objectId: Long, @@ -639,9 +635,9 @@ suspend fun MapViewModel.loadDamagesNearby( } } -fun formatDistance(meters: Double): String { - return if (meters < 1000) { - "${meters.roundToInt()} m" +fun formatDistance(meters: Double?): String { + return if (meters == null || meters < 1000) { + "${meters?.roundToInt()} m" } else { "${"%.1f".format(meters / 1000)} km" } diff --git a/app/src/main/java/com/example/snapandsolve/ui/theme/composable/DialogContainer.kt b/app/src/main/java/com/example/snapandsolve/ui/theme/composable/DialogContainer.kt new file mode 100644 index 0000000..f862994 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/ui/theme/composable/DialogContainer.kt @@ -0,0 +1,339 @@ +package com.example.snapandsolve.ui.theme.composable + +import DamageWithDistance +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import formatDistance +import getEmojiForType + +@Composable +fun DialogContainer( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(20.dp), + maxWidthFraction: Float = 0.9f, + maxHeightFraction: Float = 0.85f, + footer: (@Composable () -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = modifier + .fillMaxWidth(maxWidthFraction) + .fillMaxHeight(maxHeightFraction), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + + // ---------- HEADER ---------- + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Schließen" + ) + } + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + // ---------- CONTENT (scrollable) ---------- + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + content() + } + + // ---------- FOOTER ---------- + if (footer != null) { + Divider(modifier = Modifier.padding(vertical = 16.dp)) + footer() + } + } + } + } +} + +@Composable +fun EqualWidthButtonRow( + modifier: Modifier = Modifier, + title: String? = null, + spacing: Dp = 12.dp, + content: @Composable RowScope.() -> Unit +) { + Column(modifier = modifier.fillMaxWidth()) { + + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } +} + +enum class AppButtonStyle { Filled, Outlined } + +data class AppButtonColors( + val container: Color? = null, + val content: Color? = null, + val border: Color? = null +) + + +@Composable +fun AppButton( + text: () -> String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: AppButtonStyle = AppButtonStyle.Filled, + icon: ImageVector? = null, + contentDescription: String? = null, + colors: AppButtonColors? = null +) { + val scheme = MaterialTheme.colorScheme + + // 👇 Explizite, gute Defaults + val defaultContainer = scheme.primary // klassisches Blau + val defaultContent = scheme.onPrimary // weiß + val disabledContainer = scheme.surfaceVariant + val disabledContent = scheme.onSurfaceVariant + + val content: @Composable RowScope.() -> Unit = { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(2.dp)) + } + Text( + text = text(), + maxLines = Int.MAX_VALUE + ) + } + + when (style) { + AppButtonStyle.Filled -> Button( + onClick = onClick, + enabled = enabled, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = colors?.container ?: defaultContainer, + contentColor = colors?.content ?: defaultContent, + disabledContainerColor = disabledContainer, + disabledContentColor = disabledContent + ), + content = content + ) + + AppButtonStyle.Outlined -> OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = colors?.content ?: scheme.primary, + disabledContentColor = disabledContent + ), + border = BorderStroke( + 1.dp, + colors?.border ?: scheme.primary + ), + content = content + ) + } +} + +@Composable +fun DamageListItem( + damage: DamageWithDistance, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + 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() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text("📷", style = MaterialTheme.typography.headlineLarge) + } + } + } + + // Rechts: Infos + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(getEmojiForType(damage.typ), style = MaterialTheme.typography.titleLarge) + Text( + text = damage.typ, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + val shortDesc = + damage.beschreibung.take(40) + if (damage.beschreibung.length > 40) "..." else "" + + Text( + text = shortDesc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + + 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 + ) + } + + 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, + color = if (damage.rating > 0) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.outline + ) + } + } + } + } + } +} + + + + + + + + + diff --git a/app/src/main/java/com/example/snapandsolve/ui/theme/composable/Legende.kt b/app/src/main/java/com/example/snapandsolve/ui/theme/composable/Legende.kt new file mode 100644 index 0000000..4211f90 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/ui/theme/composable/Legende.kt @@ -0,0 +1,64 @@ +package com.example.snapandsolve.ui.theme.composable + +import androidx.compose.ui.graphics.Color +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + + +@Composable +fun LegendItem( + label: String, + modifier: Modifier = Modifier, + marker: @Composable () -> Unit +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(20.dp), + contentAlignment = Alignment.Center + ) { + marker() + } + Spacer(Modifier.width(8.dp)) + Text(label) + } +} + +@Composable +fun LegendMarkerCircle(color: Color) { + Box( + modifier = Modifier + .size(12.dp) + .background(color, CircleShape) + ) +} + +@Composable +fun LegendMarkerIcon( + @DrawableRes iconRes: Int, + tint: Color? = null +) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = tint ?: Color.Black + ) +} + + diff --git a/app/src/main/java/com/example/snapandsolve/ui/theme/SideSlider.kt b/app/src/main/java/com/example/snapandsolve/ui/theme/composable/SideSlider.kt similarity index 95% rename from app/src/main/java/com/example/snapandsolve/ui/theme/SideSlider.kt rename to app/src/main/java/com/example/snapandsolve/ui/theme/composable/SideSlider.kt index c68ed61..9d35d0a 100644 --- a/app/src/main/java/com/example/snapandsolve/ui/theme/SideSlider.kt +++ b/app/src/main/java/com/example/snapandsolve/ui/theme/composable/SideSlider.kt @@ -1,4 +1,4 @@ -package com.example.snapandsolve.ui.theme +package com.example.snapandsolve.ui.theme.composable import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -15,6 +15,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.graphics.vector.ImageVector +import com.example.snapandsolve.ui.theme.WidgetColor @Composable diff --git a/app/src/main/java/com/example/snapandsolve/view/CloseDamageDialog.kt b/app/src/main/java/com/example/snapandsolve/view/CloseDamageDialog.kt new file mode 100644 index 0000000..641d4d0 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/view/CloseDamageDialog.kt @@ -0,0 +1,60 @@ +package com.example.snapandsolve.view + +import DamageWithDistance +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.snapandsolve.ui.theme.composable.AppButton +import com.example.snapandsolve.ui.theme.composable.AppButtonStyle +import com.example.snapandsolve.ui.theme.composable.DamageListItem +import com.example.snapandsolve.ui.theme.composable.DialogContainer +import com.example.snapandsolve.ui.theme.composable.EqualWidthButtonRow + +@Composable +fun CloseDamageDialog( + hits: List, + onDismiss: () -> Unit, + onProceedAnyway: () -> Unit, +) { + DialogContainer( + title = "Ähnliche Schäden in der Nähe", + onDismiss = onDismiss, + footer = { + EqualWidthButtonRow { + AppButton( + text = { "Abbrechen" }, + style = AppButtonStyle.Outlined, + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) + AppButton( + text = { "Trotzdem hinzufügen" }, + style = AppButtonStyle.Filled, + onClick = onProceedAnyway, + modifier = Modifier.weight(1f) + ) + } + } + ) { + Text( + text = "Es wurden ${hits.size} ähnliche Meldung(en) in deiner Nähe.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(12.dp)) + + // Liste + hits.forEach { hit -> + DamageListItem( + damage = hit, + onClick = {} + ) + Spacer(Modifier.height(10.dp)) + } + } +} diff --git a/app/src/main/java/com/example/snapandsolve/view/ReportDialog.kt b/app/src/main/java/com/example/snapandsolve/view/ReportDialog.kt new file mode 100644 index 0000000..7242166 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/view/ReportDialog.kt @@ -0,0 +1,253 @@ +package com.example.snapandsolve.view + +import MapViewModel +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.example.snapandsolve.camera.AlbumViewModel +import com.example.snapandsolve.camera.AlbumViewState +import com.example.snapandsolve.camera.Intent +import com.example.snapandsolve.ui.theme.composable.AppButton +import com.example.snapandsolve.ui.theme.composable.AppButtonStyle +import com.example.snapandsolve.ui.theme.composable.DialogContainer +import com.example.snapandsolve.ui.theme.composable.EqualWidthButtonRow +import kotlinx.coroutines.launch + +@Composable +fun ReportDialog( + onCancel: () -> Unit, + onClose: () -> Unit, + viewModel: AlbumViewModel, + mapViewModel: MapViewModel +) { + val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState() + val currentContext = LocalContext.current + val hasPoint = mapViewModel.reportDraft.point != null + var dropdownExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var showDuplicateDialog by remember { mutableStateOf(false) } + var checking by remember { mutableStateOf(false) } + + LaunchedEffect(viewState.selectedPictures) { + mapViewModel.updateReportDraft { copy(photos = viewState.selectedPictures) } + } + + val pickImageFromAlbumLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(20) + ) { urls -> + viewModel.onReceive(Intent.OnFinishPickingImagesWith(currentContext, urls)) + } + + val cameraLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture() + ) { isImageSaved -> + if (isImageSaved) viewModel.onReceive(Intent.OnImageSavedWith(currentContext)) + else viewModel.onReceive(Intent.OnImageSavingCanceled) + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { permissionGranted -> + if (permissionGranted) viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext)) + else viewModel.onReceive(Intent.OnPermissionDenied) + } + + fun startCamera() { + val hasPermission = + currentContext.checkSelfPermission(Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (hasPermission) viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext)) + else permissionLauncher.launch(Manifest.permission.CAMERA) + } + + LaunchedEffect(viewState.tempFileUrl) { + viewState.tempFileUrl?.let { cameraLauncher.launch(it) } + } + + DialogContainer( + title = "Neue Meldung", + onDismiss = onCancel, + maxWidthFraction = 0.98f, + maxHeightFraction = 0.95f, + contentPadding = PaddingValues(16.dp), + footer = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + AppButton( + text = { "Hinzufügen" }, + style = AppButtonStyle.Outlined, + onClick = { + scope.launch { + checking = true + showDuplicateDialog = mapViewModel.isDuplicateNearby(20.0) + checking = false + + if (!showDuplicateDialog) { + mapViewModel.submitDraftToLayer() + viewModel.clearSelection() + onCancel() + } + } + }, + enabled = mapViewModel.reportDraft.isValid, + modifier = Modifier.fillMaxWidth(0.7f) + ) + } + } + ) { + // ---------- CONTENT ---------- + Text( + text = "Schadensbeschreibung:", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 2.dp) + ) + + // Typ Dropdown + Box { + OutlinedButton( + onClick = { dropdownExpanded = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Typ: ${mapViewModel.reportDraft.typ}") + } + + DropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false } + ) { + MapViewModel.DAMAGE_TYPES.forEach { typ -> + DropdownMenuItem( + text = { Text(typ) }, + onClick = { + mapViewModel.updateReportDraft { copy(typ = typ) } + dropdownExpanded = false + } + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + // Kamera Buttons + EqualWidthButtonRow(title = "Foto aufnehmen...") { + AppButton( + text = { "Foto aufnehmen" }, + style = AppButtonStyle.Filled, + modifier = Modifier.weight(1f), + onClick = { startCamera() } + ) + AppButton( + text = { "Aus Galerie" }, + style = AppButtonStyle.Filled, + modifier = Modifier.weight(1f), + onClick = { + pickImageFromAlbumLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) + } + + Spacer(Modifier.height(12.dp)) + + // Beschreibung + TextField( + value = mapViewModel.reportDraft.beschreibung, + onValueChange = { text -> + mapViewModel.updateReportDraft { copy(beschreibung = text) } + }, + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + placeholder = { Text("Beschreibung eingeben...") }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White + ) + ) + + Spacer(Modifier.height(12.dp)) + + // Bilder Grid + if (viewState.selectedPictures.isNotEmpty()) { + Text("Ausgewählte Bilder (${viewState.selectedPictures.size})") + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + userScrollEnabled = false, + modifier = Modifier + .fillMaxWidth() + .heightIn(0.dp, 200.dp) + ) { + itemsIndexed(viewState.selectedPictures) { index, picture -> + Image( + modifier = Modifier.padding(4.dp), + bitmap = picture, + contentDescription = "Bild ${index + 1}", + contentScale = ContentScale.Crop + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + // Position Buttons + EqualWidthButtonRow(title = "Position...") { + AppButton( + text = { if (hasPoint) "neu aus Standort" else "aus Standort" }, + style = AppButtonStyle.Filled, + modifier = Modifier.weight(1f), + onClick = { mapViewModel.pickCurrentLocation() } + ) + AppButton( + text = { if (hasPoint) "manuell neu setzen" else "manuell setzen" }, + style = AppButtonStyle.Filled, + modifier = Modifier.weight(1f), + onClick = { + mapViewModel.startPickReportLocation() + onClose() + } + ) + } + } + if (showDuplicateDialog) { + CloseDamageDialog( + hits = mapViewModel.duplicateDamages, + onDismiss = { + showDuplicateDialog = false + mapViewModel.clearDuplicateDamages() + }, + onProceedAnyway = { + mapViewModel.submitDraftToLayer() + viewModel.clearSelection() + showDuplicateDialog = false + mapViewModel.clearDuplicateDamages() + } + ) + } + +} diff --git a/app/src/main/java/com/example/snapandsolve/view/StatusSymbolRenderer.kt b/app/src/main/java/com/example/snapandsolve/view/StatusSymbolRenderer.kt new file mode 100644 index 0000000..2575d35 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/view/StatusSymbolRenderer.kt @@ -0,0 +1,86 @@ +package com.example.snapandsolve.view + +import com.arcgismaps.Color +import com.arcgismaps.mapping.symbology.* +import kotlin.collections.iterator + +fun createTypStatusRenderer(): UniqueValueRenderer { + + // Status -> Hintergrundfarbe + val statusColor = mapOf( + "neu" to Color.fromRgba(220, 50, 50, 255), + "in Bearbeitung" to Color.fromRgba(255, 180, 0, 255), + "Schaden behoben" to Color.fromRgba(60, 180, 75, 255) + ) + + // Typ -> Icon-Style (aus Standardbibliothek) + val typeIcon = mapOf( + "Straße" to SimpleMarkerSymbolStyle.Square, + "Gehweg" to SimpleMarkerSymbolStyle.Triangle, + "Fahrradweg" to SimpleMarkerSymbolStyle.Diamond, + "Beleuchtung" to SimpleMarkerSymbolStyle.X, + "Sonstiges" to SimpleMarkerSymbolStyle.Cross + ) + + val renderer = UniqueValueRenderer().apply { + fieldNames.addAll(listOf("typ", "status")) + + defaultLabel = "Sonstige" + defaultSymbol = makeStatusTypeSymbol( + iconStyle = SimpleMarkerSymbolStyle.Circle, + backgroundFill = Color.fromRgba(180, 180, 180, 255) + ) + } + + for ((typ, iconStyle) in typeIcon) { + for ((status, bgColor) in statusColor) { + val label = "$typ • $status" + renderer.uniqueValues.add( + UniqueValue( + label = label, + description = label, + symbol = makeStatusTypeSymbol(iconStyle, bgColor), + values = listOf(typ, status) + ) + ) + } + } + + return renderer +} + +fun makeStatusTypeSymbol( + iconStyle: SimpleMarkerSymbolStyle, + backgroundFill: Color +): Symbol { + val white = Color.fromRgba(255, 255, 255, 255) + + // Hintergrund: Kreis in Statusfarbe + val background = SimpleMarkerSymbol( + style = SimpleMarkerSymbolStyle.Circle, + color = backgroundFill, + size = 18f + ).apply { + outline = SimpleLineSymbol( + style = SimpleLineSymbolStyle.Solid, + color = white, + width = 1.5f + ) + } + + // Vordergrund: "Icon" als Outline (weiß), ohne Füllung + val foreground = SimpleMarkerSymbol( + style = iconStyle, + color = Color.fromRgba(0, 0, 0, 0), // transparent => nur Outline sichtbar + size = 10f + ).apply { + outline = SimpleLineSymbol( + style = SimpleLineSymbolStyle.Solid, + color = white, + width = 2.0f + ) + } + + return CompositeSymbol(listOf(background, foreground)) +} + diff --git a/app/src/main/java/com/example/snapandsolve/viewmodel/NearbyDamageCheck.kt b/app/src/main/java/com/example/snapandsolve/viewmodel/NearbyDamageCheck.kt new file mode 100644 index 0000000..0f263db --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/viewmodel/NearbyDamageCheck.kt @@ -0,0 +1,79 @@ +package com.example.snapandsolve.viewmodel + +import DamageWithDistance +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import com.arcgismaps.data.ArcGISFeature +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 + +suspend fun findNearbyDamageOfSameType( + table: ServiceFeatureTable, + draftPoint: Point, + draftTyp: String, + radiusMeters: Double +): List { + + val layerSR: SpatialReference? = table.spatialReference + val pointInLayerSR = if (layerSR != null && draftPoint.spatialReference != layerSR) { + (GeometryEngine.projectOrNull(draftPoint, layerSR) as? Point) ?: draftPoint + } else { + draftPoint + } + + val buffer = GeometryEngine.bufferOrNull(pointInLayerSR, radiusMeters) ?: return emptyList() + + val safeTyp = draftTyp.replace("'", "''") + val qp = QueryParameters().apply { + whereClause = "typ = '$safeTyp'" + geometry = buffer + } + + val result = table.queryFeatures(qp).getOrThrow() + + val hits = mutableListOf() + + for (feature in result) { + val p = feature.geometry as? Point ?: continue + + // ✅ Distanz korrekt + val dist = GeometryEngine.distanceOrNull(pointInLayerSR, p) ?: Double.POSITIVE_INFINITY + + val typ = (feature.attributes["typ"] as? String).orEmpty() + val beschreibung = (feature.attributes["beschreibung"] as? String).orEmpty() + val rating = (feature.attributes["communitycounter"] as? Number)?.toInt() ?: 0 + val id = (feature.attributes["OBJECTID"] as? Number)?.toLong() ?: 0L + val photo: ImageBitmap? = try { + val arcFeature = feature as? ArcGISFeature + val attachments = arcFeature?.fetchAttachments()?.getOrNull() + val first = attachments?.firstOrNull() + + // Je nach SDK: first.fetchData() oder first.data + val bytes: ByteArray? = first + ?.fetchData() + ?.getOrNull() + + bytes?.let { + BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() + } + } catch (_: Exception) { + null + } + + hits += DamageWithDistance( + feature = feature as ArcGISFeature, + typ = typ, + beschreibung = beschreibung, + photo = photo, + rating = rating, + distanceInMeters = dist, + objectId = id + ) + } + + return hits.sortedBy { it.distanceInMeters } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b72ef65..e5ddbcb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ activityCompose = "1.12.1" composeBom = "2024.09.00" material3 = "1.4.0" arcgisMapsKotlin = "200.8.0" +runtime = "1.10.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -31,6 +32,7 @@ arcgis-maps-kotlin = { group = "com.esri", name = "arcgis-maps-kotlin", version. arcgis-maps-kotlin-toolkit-bom = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-bom", version.ref = "arcgisMapsKotlin" } arcgis-maps-kotlin-toolkit-geoview-compose = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-geoview-compose" } arcgis-maps-kotlin-toolkit-authentication = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-authentication" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }