- Filtern nach Status

- App Meldungen über nahe Schäden erhalten.
This commit is contained in:
2026-02-08 12:35:11 +01:00
parent 7781551e02
commit 7d6bf0fdd7
9 changed files with 707 additions and 67 deletions

View File

@@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-25T17:20:21.835673100Z"> <DropdownSelection timestamp="2026-02-06T10:29:08.485527Z">
<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" />

View File

@@ -10,6 +10,10 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -19,6 +23,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SnapAndSolve"> android:theme="@style/Theme.SnapAndSolve">
<activity <activity
android:name="com.example.snapandsolve.MainActivity" android:name="com.example.snapandsolve.MainActivity"
android:exported="true" android:exported="true"
@@ -26,10 +31,17 @@
android:theme="@style/Theme.SnapAndSolve"> android:theme="@style/Theme.SnapAndSolve">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Service für Proximity-Benachrichtigungen -->
<service
android:name=".service.ProximityNotificationService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"

View File

@@ -1,5 +1,6 @@
package com.example.snapandsolve package com.example.snapandsolve
import MainScreen
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent

View File

@@ -1,5 +1,3 @@
package com.example.snapandsolve
import DamageFilterDialog import DamageFilterDialog
import DamageListDialog import DamageListDialog
import MapViewModel import MapViewModel
@@ -10,6 +8,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FormatListNumbered import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -18,10 +18,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import applyDamageFilter import applyDamageFilter
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.toolkit.geoviewcompose.MapView
import com.example.snapandsolve.FeatureInfoDialog
import com.example.snapandsolve.MapSegment
import com.example.snapandsolve.camera.AlbumViewModel import com.example.snapandsolve.camera.AlbumViewModel
import com.example.snapandsolve.ui.theme.* import com.example.snapandsolve.ui.theme.*
import com.example.snapandsolve.ui.theme.composable.SideSlider import com.example.snapandsolve.ui.theme.composable.SideSlider
import com.example.snapandsolve.ui.theme.composable.SliderMenuItem import com.example.snapandsolve.ui.theme.composable.SliderMenuItem
import com.example.snapandsolve.updateFeatureRating
import com.example.snapandsolve.view.ReportDialog import com.example.snapandsolve.view.ReportDialog
import getActiveFilters import getActiveFilters
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -32,6 +40,8 @@ import kotlinx.coroutines.launch
fun MainScreen(modifier: Modifier = Modifier, application: Application) { fun MainScreen(modifier: Modifier = Modifier, application: Application) {
var showReport by rememberSaveable { mutableStateOf(false) } var showReport by rememberSaveable { mutableStateOf(false) }
var sliderOpen by rememberSaveable { mutableStateOf(false) } var sliderOpen by rememberSaveable { mutableStateOf(false) }
var showSettings by rememberSaveable { mutableStateOf(false) }
val mapViewModel = remember { MapViewModel(application) } val mapViewModel = remember { MapViewModel(application) }
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) } val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
@@ -44,6 +54,15 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
showReport = false showReport = false
} }
fun openSettings() {
showSettings = true
sliderOpen = false
}
fun closeSettings() {
showSettings = false
}
LaunchedEffect(mapViewModel.reopenReport) { LaunchedEffect(mapViewModel.reopenReport) {
if (mapViewModel.reopenReport) { if (mapViewModel.reopenReport) {
showReport = true showReport = true
@@ -51,7 +70,6 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
} }
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { AppTopBar() }, topBar = { AppTopBar() },
@@ -60,7 +78,7 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
modifier = Modifier.height(120.dp), modifier = Modifier.height(120.dp),
containerColor = AppColor, containerColor = AppColor,
) { ) {
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false }) { IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false; showSettings = false }) {
Icon(Icons.Default.Menu, contentDescription = "Menu") Icon(Icons.Default.Menu, contentDescription = "Menu")
} }
} }
@@ -80,14 +98,22 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
}, },
floatingActionButtonPosition = FabPosition.Center, floatingActionButtonPosition = FabPosition.Center,
) { innerPadding -> ) { innerPadding ->
ContentScreen( if (showSettings) {
modifier = Modifier.padding(innerPadding), SettingsScreen(
mapViewModel = mapViewModel, onBack = ::closeSettings,
albumViewModel = albumViewModel, mapViewModel = mapViewModel
showReport = showReport, )
sliderOpen = sliderOpen, } else {
onDismissReport = ::closeReport ContentScreen(
) modifier = Modifier.padding(innerPadding),
mapViewModel = mapViewModel,
albumViewModel = albumViewModel,
showReport = showReport,
sliderOpen = sliderOpen,
onDismissReport = ::closeReport,
onOpenSettings = ::openSettings
)
}
} }
} }
@@ -98,14 +124,13 @@ fun ContentScreen(
albumViewModel: AlbumViewModel, albumViewModel: AlbumViewModel,
showReport: Boolean, showReport: Boolean,
sliderOpen: Boolean, sliderOpen: Boolean,
onDismissReport: () -> Unit onDismissReport: () -> Unit,
onOpenSettings: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// NEU: State für Filter-Dialog
var showFilterDialog by remember { mutableStateOf(false) } var showFilterDialog by remember { mutableStateOf(false) }
var showDamageList by remember { mutableStateOf(false) } var showDamageList by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
@@ -150,15 +175,15 @@ fun ContentScreen(
) )
} }
// Filter Dialog - NEU! // Filter Dialog - AKTUALISIERT mit Status-Parameter
if (showFilterDialog) { if (showFilterDialog) {
DamageFilterDialog( DamageFilterDialog(
damageTypes = MapViewModel.DAMAGE_TYPES, // <-- Nutzt zentrale Liste damageTypes = MapViewModel.DAMAGE_TYPES,
currentFilters = mapViewModel.getActiveFilters(), currentFilters = mapViewModel.getActiveFilters(),
onDismiss = { showFilterDialog = false }, onDismiss = { showFilterDialog = false },
onApplyFilter = { selectedTypes, startDate, endDate -> onApplyFilter = { selectedTypes, selectedStatus, startDate, endDate ->
coroutineScope.launch { coroutineScope.launch {
mapViewModel.applyDamageFilter(selectedTypes, startDate, endDate) mapViewModel.applyDamageFilter(selectedTypes, selectedStatus, startDate, endDate)
} }
} }
) )
@@ -172,12 +197,16 @@ fun ContentScreen(
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
SliderMenuItem(
text = "Einstellungen",
icon = Icons.Default.Settings,
onClick = onOpenSettings
)
SliderMenuItem( SliderMenuItem(
text = "Schäden filtern", text = "Schäden filtern",
icon = Icons.Default.FilterAlt, icon = Icons.Default.FilterAlt,
onClick = { onClick = { showFilterDialog = true }
showFilterDialog = true // <-- Öffnet Filter-Dialog
}
) )
SliderMenuItem( SliderMenuItem(
@@ -224,5 +253,4 @@ suspend fun loadFirstAttachmentBitmap(
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size) val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
return bitmap.asImageBitmap() return bitmap.asImageBitmap()
} }
*/ */

View File

@@ -423,6 +423,10 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
fun clearDuplicateDamages() { fun clearDuplicateDamages() {
duplicateDamages = emptyList() duplicateDamages = emptyList()
} }
fun getFeatureTable(): ServiceFeatureTable? {
return featureLayer?.featureTable as? ServiceFeatureTable
}
} }
enum class FeatureOperationType(val operationName: String, val instruction: String) { enum class FeatureOperationType(val operationName: String, val instruction: String) {

View File

@@ -0,0 +1,295 @@
package com.example.snapandsolve.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.data.QueryParameters
import com.arcgismaps.data.ServiceFeatureTable
import com.arcgismaps.geometry.GeometryEngine
import com.arcgismaps.geometry.GeodeticCurveType
import com.arcgismaps.geometry.LinearUnit
import com.arcgismaps.geometry.LinearUnitId
import com.arcgismaps.geometry.Point
import com.arcgismaps.geometry.SpatialReference
import com.arcgismaps.location.SystemLocationDataSource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ProximityNotificationService : Service() {
private var locationDataSource: SystemLocationDataSource? = null
private var featureTable: ServiceFeatureTable? = null
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val notifiedFeatures = mutableSetOf<String>()
companion object {
const val CHANNEL_ID = "proximity_notifications"
const val NOTIFICATION_ID = 1001
const val FOREGROUND_SERVICE_ID = 1000
const val PROXIMITY_RADIUS_METERS = 100.0
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()
private var sharedFeatureTable: ServiceFeatureTable? = null
fun start(context: Context, featureTable: ServiceFeatureTable) {
sharedFeatureTable = featureTable
val intent = Intent(context, ProximityNotificationService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
sharedFeatureTable = null
context.stopService(Intent(context, ProximityNotificationService::class.java))
}
}
override fun onCreate() {
super.onCreate()
Log.d("ProximityService", "Service onCreate()")
createNotificationChannel()
startForeground(FOREGROUND_SERVICE_ID, createForegroundNotification())
_isRunning.value = true
featureTable = sharedFeatureTable
if (featureTable == null) {
Log.e("ProximityService", "FeatureTable is NULL in onCreate!")
} else {
Log.d("ProximityService", "FeatureTable successfully assigned")
Log.d("ProximityService", "FeatureTable SpatialReference: ${featureTable?.spatialReference?.wkid}")
}
startLocationTracking()
}
private fun startLocationTracking() {
locationDataSource = SystemLocationDataSource().apply {
serviceScope.launch {
start().onSuccess {
Log.d("ProximityService", "Location tracking started")
locationChanged.collect { location ->
Log.d("ProximityService", "Location update: ${location.position.x}, ${location.position.y}")
checkProximityToDamages(location)
}
}.onFailure { error ->
Log.e("ProximityService", "Failed to start location tracking: ${error.message}")
}
}
}
}
override fun onDestroy() {
super.onDestroy()
Log.d("ProximityService", "Service onDestroy()")
serviceScope.launch {
locationDataSource?.stop()
}
serviceScope.cancel()
_isRunning.value = false
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Straßenschaden-Benachrichtigungen",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Benachrichtigungen über Straßenschäden in Ihrer Nähe"
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun createForegroundNotification(): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Straßenschaden-Überwachung")
.setContentText("Überwacht Straßenschäden in Ihrer Nähe")
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private suspend fun checkProximityToDamages(location: com.arcgismaps.location.Location) {
Log.d("ProximityService", "Checking proximity at: ${location.position.x}, ${location.position.y}")
if (featureTable == null) {
Log.e("ProximityService", "FeatureTable is NULL! Cannot check proximity")
return
}
// GPS Position (WGS84)
val currentPoint = Point(
location.position.x,
location.position.y,
SpatialReference.wgs84()
)
Log.d("ProximityService", "Current point SR: ${currentPoint.spatialReference?.wkid}")
val queryParams = QueryParameters().apply {
whereClause = "1=1"
returnGeometry = true
}
try {
val queryResult = featureTable?.queryFeatures(queryParams)?.getOrNull()
val features = queryResult?.map { it as ArcGISFeature } ?: emptyList()
Log.d("ProximityService", "Found ${features.size} features total")
features.forEach { feature ->
val featureGeometry = feature.geometry as? Point ?: return@forEach
Log.d("ProximityService", "Feature point SR: ${featureGeometry.spatialReference?.wkid}")
// KRITISCHER FIX: Beide Punkte ins gleiche Koordinatensystem bringen
val projectedCurrentPoint = if (currentPoint.spatialReference?.wkid != featureGeometry.spatialReference?.wkid) {
GeometryEngine.projectOrNull(currentPoint, featureGeometry.spatialReference!!) as? Point
} else {
currentPoint
}
if (projectedCurrentPoint == null) {
Log.e("ProximityService", "Failed to project current point")
return@forEach
}
// KORRIGIERTE Distanzberechnung mit GeometryEngine
val distanceResult = GeometryEngine.distanceGeodeticOrNull(
point1 = projectedCurrentPoint,
point2 = featureGeometry,
distanceUnit = LinearUnit(LinearUnitId.Meters),
azimuthUnit = null,
curveType = GeodeticCurveType.Geodesic
)
val distance = distanceResult?.distance ?: return@forEach
Log.d("ProximityService", "Feature distance: ${String.format("%.2f", distance)}m")
if (distance <= PROXIMITY_RADIUS_METERS) {
val featureId = feature.attributes["OBJECTID"]?.toString() ?: return@forEach
if (!notifiedFeatures.contains(featureId)) {
Log.d("ProximityService", "Sending notification for feature $featureId at ${String.format("%.2f", distance)}m")
notifiedFeatures.add(featureId)
sendDamageNotification(feature, distance)
} else {
Log.d("ProximityService", "Already notified for feature $featureId")
}
}
}
cleanupNotifiedFeatures(currentPoint, features)
} catch (e: Exception) {
Log.e("ProximityService", "Error checking proximity", e)
e.printStackTrace()
}
}
private fun sendDamageNotification(feature: ArcGISFeature, distance: Double) {
// Versuche verschiedene Attribut-Namen für die Kategorie
val kategorie = feature.attributes["Kategorie"]?.toString()
?: feature.attributes["kategorie"]?.toString()
?: feature.attributes["Category"]?.toString()
?: feature.attributes["category"]?.toString()
?: "Straßenschaden" // Fallback
// Versuche verschiedene Attribut-Namen für die Beschreibung
val beschreibung = feature.attributes["Beschreibung"]?.toString()
?: feature.attributes["beschreibung"]?.toString()
?: feature.attributes["Description"]?.toString()
?: feature.attributes["description"]?.toString()
val distanceText = String.format("%.0f", distance)
// Baue den Notification-Text
val notificationText = if (beschreibung != null && beschreibung.isNotEmpty()) {
"$kategorie: $beschreibung - ca. ${distanceText}m entfernt"
} else {
"$kategorie - ca. ${distanceText}m entfernt"
}
Log.d("ProximityService", "Notification text: $notificationText")
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("⚠️ Straßenschaden in Ihrer Nähe!")
.setContentText(notificationText)
.setStyle(NotificationCompat.BigTextStyle().bigText(notificationText)) // Für längere Texte
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notificationId = feature.attributes["OBJECTID"]?.toString()?.hashCode() ?: NOTIFICATION_ID
Log.d("ProximityService", "Displaying notification for: $kategorie")
notificationManager.notify(notificationId, notification)
}
private fun cleanupNotifiedFeatures(currentPoint: Point, features: List<ArcGISFeature>) {
val featuresToRemove = mutableListOf<String>()
notifiedFeatures.forEach { featureId ->
val feature = features.find { (it.attributes["OBJECTID"]?.toString() ?: "") == featureId }
if (feature == null) {
featuresToRemove.add(featureId)
return@forEach
}
val featureGeometry = feature.geometry as? Point ?: return@forEach
val projectedCurrentPoint = if (currentPoint.spatialReference?.wkid != featureGeometry.spatialReference?.wkid) {
GeometryEngine.projectOrNull(currentPoint, featureGeometry.spatialReference!!) as? Point
} else {
currentPoint
}
if (projectedCurrentPoint == null) return@forEach
val distanceResult = GeometryEngine.distanceGeodeticOrNull(
point1 = projectedCurrentPoint,
point2 = featureGeometry,
distanceUnit = LinearUnit(LinearUnitId.Meters),
azimuthUnit = null,
curveType = GeodeticCurveType.Geodesic
)
val distance = distanceResult?.distance ?: return@forEach
if (distance > PROXIMITY_RADIUS_METERS + 50) {
featuresToRemove.add(featureId)
Log.d("ProximityService", "Removing feature $featureId from cache (distance: ${String.format("%.2f", distance)}m)")
}
}
notifiedFeatures.removeAll(featuresToRemove)
}
}

View File

@@ -25,7 +25,8 @@ import java.time.format.DateTimeFormatter
fun FilterCheckboxItem( fun FilterCheckboxItem(
label: String, label: String,
isChecked: Boolean, isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit onCheckedChange: (Boolean) -> Unit,
emoji: String = ""
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -47,14 +48,7 @@ fun FilterCheckboxItem(
) )
Text( Text(
text = when (label) { text = emoji,
"Straße" -> "🛣️"
"Gehweg" -> "🚶"
"Fahrradweg" -> "🚴"
"Beleuchtung" -> "💡"
"Sonstiges" -> "📍"
else -> ""
},
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
} }
@@ -62,22 +56,28 @@ fun FilterCheckboxItem(
/** /**
* Dialog für Schaden-Filter * Dialog für Schaden-Filter
* Ermöglicht das Filtern von Features nach Typ UND Datum (unabhängig voneinander) * Ermöglicht das Filtern von Features nach Typ, Status UND Datum (unabhängig voneinander)
*/ */
@Composable @Composable
fun DamageFilterDialog( fun DamageFilterDialog(
damageTypes: List<String>, damageTypes: List<String>,
currentFilters: Set<String>, currentFilters: Set<String>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onApplyFilter: (Set<String>, LocalDate?, LocalDate?) -> Unit onApplyFilter: (Set<String>, Set<String>, LocalDate?, LocalDate?) -> Unit
) { ) {
// Status-Liste
val statusTypes = listOf("neu", "in Bearbeitung", "Schaden behoben")
// WICHTIG: Wenn keine Filter aktiv sind, alle Typen standardmäßig auswählen! // WICHTIG: Wenn keine Filter aktiv sind, alle Typen standardmäßig auswählen!
// Das ermöglicht Datumfilterung ohne Typ-Auswahl
var selectedFilters by remember { var selectedFilters by remember {
mutableStateOf( mutableStateOf(
if (currentFilters.isEmpty()) damageTypes.toSet() else currentFilters if (currentFilters.isEmpty()) damageTypes.toSet() else currentFilters
) )
} }
// Status-Filter: Standardmäßig alle ausgewählt
var selectedStatus by remember { mutableStateOf(statusTypes.toSet()) }
var startDateString by remember { mutableStateOf("") } var startDateString by remember { mutableStateOf("") }
var endDateString by remember { mutableStateOf("") } var endDateString by remember { mutableStateOf("") }
var startDate by remember { mutableStateOf<LocalDate?>(null) } var startDate by remember { mutableStateOf<LocalDate?>(null) }
@@ -147,6 +147,51 @@ fun DamageFilterDialog(
} else { } else {
selectedFilters - type selectedFilters - type
} }
},
emoji = when (type) {
"Straße" -> "🛣️"
"Gehweg" -> "🚶"
"Fahrradweg" -> "🚴"
"Beleuchtung" -> "💡"
"Sonstiges" -> "📍"
else -> ""
}
)
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// ===== STATUS-FILTER =====
Text(
text = "Bearbeitungsstatus:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
Text(
text = "Wähle die Status aus, die angezeigt werden sollen:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
// Status-Liste
statusTypes.forEach { status ->
FilterCheckboxItem(
label = status,
isChecked = selectedStatus.contains(status),
onCheckedChange = { isChecked ->
selectedStatus = if (isChecked) {
selectedStatus + status
} else {
selectedStatus - status
}
},
emoji = when (status) {
"neu" -> "🔴"
"in Bearbeitung" -> "🟠"
"Schaden behoben" -> "🟢"
else -> ""
} }
) )
} }
@@ -246,17 +291,26 @@ fun DamageFilterDialog(
Divider(modifier = Modifier.padding(vertical = 16.dp)) Divider(modifier = Modifier.padding(vertical = 16.dp))
// Info über aktive Filter // Info über aktive Filter
if (selectedFilters.isNotEmpty() || useDateFilter) { if (selectedFilters.isNotEmpty() || selectedStatus.size < statusTypes.size || useDateFilter) {
Text( Text(
text = buildString { text = buildString {
// Typ-Info
if (selectedFilters.size < damageTypes.size) { if (selectedFilters.size < damageTypes.size) {
append("${selectedFilters.size} von ${damageTypes.size} Typ(en)") append("${selectedFilters.size} von ${damageTypes.size} Typ(en)")
} else { } else {
append("Alle Typen") append("Alle Typen")
} }
// Status-Info
if (selectedStatus.size < statusTypes.size) {
append(" | ${selectedStatus.size} von ${statusTypes.size} Status")
} else {
append(" | Alle Status")
}
// Datum-Info
if (useDateFilter) { if (useDateFilter) {
if (selectedFilters.isNotEmpty()) append(" | ") append(" | Datum: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}")
append("Datum: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}")
} }
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -268,22 +322,28 @@ fun DamageFilterDialog(
// Buttons // Buttons
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Alle auswählen // Alle Typen
OutlinedButton( OutlinedButton(
onClick = { selectedFilters = damageTypes.toSet() }, onClick = {
selectedFilters = damageTypes.toSet()
selectedStatus = statusTypes.toSet()
},
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Alle Typen") Text("Alle", maxLines = 1)
} }
// Alle abwählen // Keine Typen
OutlinedButton( OutlinedButton(
onClick = { selectedFilters = emptySet() }, onClick = {
selectedFilters = emptySet()
selectedStatus = emptySet()
},
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Keine") Text("Keine", maxLines = 1)
} }
} }
@@ -294,14 +354,14 @@ fun DamageFilterDialog(
onClick = { onClick = {
val startDateToApply = if (useDateFilter) startDate else null val startDateToApply = if (useDateFilter) startDate else null
val endDateToApply = if (useDateFilter) endDate else null val endDateToApply = if (useDateFilter) endDate else null
onApplyFilter(selectedFilters, startDateToApply, endDateToApply) onApplyFilter(selectedFilters, selectedStatus, startDateToApply, endDateToApply)
onDismiss() onDismiss()
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
when { when {
selectedFilters.isEmpty() && !useDateFilter -> "Alle anzeigen" selectedFilters.isEmpty() && selectedStatus.isEmpty() && !useDateFilter -> "Alle anzeigen"
else -> "Filter anwenden" else -> "Filter anwenden"
} }
) )
@@ -313,25 +373,34 @@ fun DamageFilterDialog(
/** /**
* Extension-Funktion für MapViewModel * Extension-Funktion für MapViewModel
* Wendet Filter auf den FeatureLayer an (Typ + Datum - UNABHÄNGIG) * Wendet Filter auf den FeatureLayer an (Typ + Status + Datum - UNABHÄNGIG)
*/ */
suspend fun MapViewModel.applyDamageFilter( suspend fun MapViewModel.applyDamageFilter(
selectedTypes: Set<String>, selectedTypes: Set<String>,
selectedStatus: Set<String>,
startDate: LocalDate? = null, startDate: LocalDate? = null,
endDate: LocalDate? = null endDate: LocalDate? = null
): Boolean { ): Boolean {
val statusTypes = listOf("neu", "in Bearbeitung", "Schaden behoben")
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val whereClauses = mutableListOf<String>() val whereClauses = mutableListOf<String>()
// ===== TYP-FILTER ===== // ===== TYP-FILTER =====
// WICHTIG: Nur hinzufügen wenn nicht ALLE Typen ausgewählt sind // WICHTIG: Nur hinzufügen wenn nicht ALLE Typen ausgewählt sind
// Wenn alle ausgewählt sind, filtere nicht nach Typ (ermöglicht reinen Datumsfilter)
if (selectedTypes.isNotEmpty() && selectedTypes.size < DAMAGE_TYPES.size) { if (selectedTypes.isNotEmpty() && selectedTypes.size < DAMAGE_TYPES.size) {
val typeList = selectedTypes.joinToString("', '", "'", "'") val typeList = selectedTypes.joinToString("', '", "'", "'")
whereClauses.add("Typ IN ($typeList)") whereClauses.add("Typ IN ($typeList)")
} }
// ===== STATUS-FILTER =====
// Nur hinzufügen wenn nicht ALLE Status ausgewählt sind
if (selectedStatus.isNotEmpty() && selectedStatus.size < statusTypes.size) {
val statusList = selectedStatus.joinToString("', '", "'", "'")
whereClauses.add("status IN ($statusList)")
}
// ===== DATUMS-FILTER ===== // ===== DATUMS-FILTER =====
// Feldname: EditDate // Feldname: EditDate
if (startDate != null || endDate != null) { if (startDate != null || endDate != null) {
@@ -355,26 +424,32 @@ suspend fun MapViewModel.applyDamageFilter(
featureLayer.definitionExpression = whereClause featureLayer.definitionExpression = whereClause
println("DEBUG: Filter angewendet: '$whereClause'") println("DEBUG: Filter angewendet: '$whereClause'")
println("DEBUG: Typen: ${selectedTypes.size}/${DAMAGE_TYPES.size}, Datum aktiv: ${startDate != null || endDate != null}") println("DEBUG: Typen: ${selectedTypes.size}/${DAMAGE_TYPES.size}, Status: ${selectedStatus.size}/${statusTypes.size}, Datum aktiv: ${startDate != null || endDate != null}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
snackBarMessage = when { snackBarMessage = buildString {
selectedTypes.isEmpty() && (startDate == null && endDate == null) -> { val parts = mutableListOf<String>()
"Alle Schäden werden angezeigt"
// Typ-Info
if (selectedTypes.size < DAMAGE_TYPES.size) {
parts.add("${selectedTypes.size} Typ(en)")
} }
selectedTypes.size == DAMAGE_TYPES.size && (startDate != null || endDate != null) -> {
// NUR Datumsfilter aktiv (alle Typen ausgewählt) // Status-Info
"Datumsfilter: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}" if (selectedStatus.size < statusTypes.size) {
parts.add("${selectedStatus.size} Status")
} }
selectedTypes.size < DAMAGE_TYPES.size && (startDate == null && endDate == null) -> {
// NUR Typ-Filter aktiv // Datum-Info
"${selectedTypes.size} Typ(en) gefiltert" if (startDate != null || endDate != null) {
val dateStr = "${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}"
parts.add("Datum: $dateStr")
} }
else -> {
// BEIDE Filter aktiv if (parts.isEmpty()) {
val typeInfo = "${selectedTypes.size} Typ(en)" append("Alle Schäden werden angezeigt")
val dateInfo = "Datum: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}" } else {
"$typeInfo | $dateInfo" append("Filter: ${parts.joinToString(" | ")}")
} }
} }
} }

View File

@@ -0,0 +1,215 @@
package com.example.snapandsolve.ui.theme
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.snapandsolve.service.ProximityNotificationService // KORREKTER IMPORT
import MapViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
mapViewModel: MapViewModel
) {
val context = LocalContext.current
val isProximityActive by ProximityNotificationService.isRunning.collectAsState()
// NEU: Notification Permission Launcher
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Toast.makeText(context, "Benachrichtigungen aktiviert", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Benachrichtigungen verweigert", Toast.LENGTH_SHORT).show()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Einstellungen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Zurück"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = AppColor,
titleContentColor = Color.White,
navigationIconContentColor = Color.White
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
// Benachrichtigungen Section
Text(
text = "Benachrichtigungen",
style = MaterialTheme.typography.titleMedium,
color = Color.Gray,
modifier = Modifier.padding(bottom = 8.dp)
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = WidgetColor
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = "Benachrichtigungen",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Straßenschäden in der Nähe",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Benachrichtigung bei Schäden im Umkreis von 100m",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
modifier = Modifier.padding(top = 4.dp)
)
}
}
Switch(
checked = isProximityActive,
onCheckedChange = { enabled ->
if (enabled) {
// NEU: Prüfe Notification Permission (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasPermission = context.checkSelfPermission(
Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!hasPermission) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return@Switch
}
}
mapViewModel.getFeatureTable()?.let { table ->
ProximityNotificationService.start(context, table)
} ?: run {
Toast.makeText(context, "Feature Table nicht verfügbar", Toast.LENGTH_SHORT).show()
}
} else {
ProximityNotificationService.stop(context)
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = ButtonColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = Color.LightGray
)
)
}
}
// Status-Anzeige
if (isProximityActive) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
colors = CardDefaults.cardColors(
containerColor = ButtonColor.copy(alpha = 0.1f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
color = ButtonColor,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "Benachrichtigungen sind aktiv",
style = MaterialTheme.typography.bodyMedium,
color = ButtonColor
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Weitere Einstellungen können hier hinzugefügt werden
Text(
text = "Informationen",
style = MaterialTheme.typography.titleMedium,
color = Color.Gray,
modifier = Modifier.padding(bottom = 8.dp)
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = WidgetColor
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Proximity-Radius",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "100 Meter",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z"/>
</vector>