- Filtern nach Status
- App Meldungen über nahe Schäden erhalten.
This commit is contained in:
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_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
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -19,6 +23,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SnapAndSolve">
|
||||
|
||||
<activity
|
||||
android:name="com.example.snapandsolve.MainActivity"
|
||||
android:exported="true"
|
||||
@@ -26,10 +31,17 @@
|
||||
android:theme="@style/Theme.SnapAndSolve">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Service für Proximity-Benachrichtigungen -->
|
||||
<service
|
||||
android:name=".service.ProximityNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.snapandsolve
|
||||
|
||||
import MainScreen
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
package com.example.snapandsolve
|
||||
|
||||
import DamageFilterDialog
|
||||
import DamageListDialog
|
||||
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.FormatListNumbered
|
||||
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.runtime.*
|
||||
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.unit.dp
|
||||
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.ui.theme.*
|
||||
import com.example.snapandsolve.ui.theme.composable.SideSlider
|
||||
import com.example.snapandsolve.ui.theme.composable.SliderMenuItem
|
||||
import com.example.snapandsolve.updateFeatureRating
|
||||
import com.example.snapandsolve.view.ReportDialog
|
||||
import getActiveFilters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -32,6 +40,8 @@ import kotlinx.coroutines.launch
|
||||
fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
var showReport by rememberSaveable { mutableStateOf(false) }
|
||||
var sliderOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val mapViewModel = remember { MapViewModel(application) }
|
||||
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
|
||||
|
||||
@@ -44,6 +54,15 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
showReport = false
|
||||
}
|
||||
|
||||
fun openSettings() {
|
||||
showSettings = true
|
||||
sliderOpen = false
|
||||
}
|
||||
|
||||
fun closeSettings() {
|
||||
showSettings = false
|
||||
}
|
||||
|
||||
LaunchedEffect(mapViewModel.reopenReport) {
|
||||
if (mapViewModel.reopenReport) {
|
||||
showReport = true
|
||||
@@ -51,7 +70,6 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = { AppTopBar() },
|
||||
@@ -60,7 +78,7 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
modifier = Modifier.height(120.dp),
|
||||
containerColor = AppColor,
|
||||
) {
|
||||
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false }) {
|
||||
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false; showSettings = false }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||
}
|
||||
}
|
||||
@@ -80,14 +98,22 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.Center,
|
||||
) { innerPadding ->
|
||||
ContentScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
mapViewModel = mapViewModel,
|
||||
albumViewModel = albumViewModel,
|
||||
showReport = showReport,
|
||||
sliderOpen = sliderOpen,
|
||||
onDismissReport = ::closeReport
|
||||
)
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
onBack = ::closeSettings,
|
||||
mapViewModel = mapViewModel
|
||||
)
|
||||
} else {
|
||||
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,
|
||||
showReport: Boolean,
|
||||
sliderOpen: Boolean,
|
||||
onDismissReport: () -> Unit
|
||||
onDismissReport: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// NEU: State für Filter-Dialog
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var showDamageList by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
@@ -150,15 +175,15 @@ fun ContentScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Filter Dialog - NEU!
|
||||
// Filter Dialog - AKTUALISIERT mit Status-Parameter
|
||||
if (showFilterDialog) {
|
||||
DamageFilterDialog(
|
||||
damageTypes = MapViewModel.DAMAGE_TYPES, // <-- Nutzt zentrale Liste
|
||||
damageTypes = MapViewModel.DAMAGE_TYPES,
|
||||
currentFilters = mapViewModel.getActiveFilters(),
|
||||
onDismiss = { showFilterDialog = false },
|
||||
onApplyFilter = { selectedTypes, startDate, endDate ->
|
||||
onApplyFilter = { selectedTypes, selectedStatus, startDate, endDate ->
|
||||
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)
|
||||
)
|
||||
|
||||
SliderMenuItem(
|
||||
text = "Einstellungen",
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onOpenSettings
|
||||
)
|
||||
|
||||
SliderMenuItem(
|
||||
text = "Schäden filtern",
|
||||
icon = Icons.Default.FilterAlt,
|
||||
onClick = {
|
||||
showFilterDialog = true // <-- Öffnet Filter-Dialog
|
||||
}
|
||||
onClick = { showFilterDialog = true }
|
||||
)
|
||||
|
||||
SliderMenuItem(
|
||||
@@ -224,5 +253,4 @@ suspend fun loadFirstAttachmentBitmap(
|
||||
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
|
||||
return bitmap.asImageBitmap()
|
||||
}
|
||||
*/
|
||||
|
||||
*/
|
||||
@@ -423,6 +423,10 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun clearDuplicateDamages() {
|
||||
duplicateDamages = emptyList()
|
||||
}
|
||||
|
||||
fun getFeatureTable(): ServiceFeatureTable? {
|
||||
return featureLayer?.featureTable as? ServiceFeatureTable
|
||||
}
|
||||
}
|
||||
|
||||
enum class FeatureOperationType(val operationName: String, val instruction: String) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import java.time.format.DateTimeFormatter
|
||||
fun FilterCheckboxItem(
|
||||
label: String,
|
||||
isChecked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
emoji: String = "•"
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -47,14 +48,7 @@ fun FilterCheckboxItem(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = when (label) {
|
||||
"Straße" -> "🛣️"
|
||||
"Gehweg" -> "🚶"
|
||||
"Fahrradweg" -> "🚴"
|
||||
"Beleuchtung" -> "💡"
|
||||
"Sonstiges" -> "📍"
|
||||
else -> "•"
|
||||
},
|
||||
text = emoji,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
@@ -62,22 +56,28 @@ fun FilterCheckboxItem(
|
||||
|
||||
/**
|
||||
* 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
|
||||
fun DamageFilterDialog(
|
||||
damageTypes: List<String>,
|
||||
currentFilters: Set<String>,
|
||||
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!
|
||||
// Das ermöglicht Datumfilterung ohne Typ-Auswahl
|
||||
var selectedFilters by remember {
|
||||
mutableStateOf(
|
||||
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 endDateString by remember { mutableStateOf("") }
|
||||
var startDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
@@ -147,6 +147,51 @@ fun DamageFilterDialog(
|
||||
} else {
|
||||
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))
|
||||
|
||||
// Info über aktive Filter
|
||||
if (selectedFilters.isNotEmpty() || useDateFilter) {
|
||||
if (selectedFilters.isNotEmpty() || selectedStatus.size < statusTypes.size || useDateFilter) {
|
||||
Text(
|
||||
text = buildString {
|
||||
// Typ-Info
|
||||
if (selectedFilters.size < damageTypes.size) {
|
||||
append("${selectedFilters.size} von ${damageTypes.size} Typ(en)")
|
||||
} else {
|
||||
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 (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,
|
||||
@@ -268,22 +322,28 @@ fun DamageFilterDialog(
|
||||
// Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Alle auswählen
|
||||
// Alle Typen
|
||||
OutlinedButton(
|
||||
onClick = { selectedFilters = damageTypes.toSet() },
|
||||
onClick = {
|
||||
selectedFilters = damageTypes.toSet()
|
||||
selectedStatus = statusTypes.toSet()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Alle Typen")
|
||||
Text("Alle", maxLines = 1)
|
||||
}
|
||||
|
||||
// Alle abwählen
|
||||
// Keine Typen
|
||||
OutlinedButton(
|
||||
onClick = { selectedFilters = emptySet() },
|
||||
onClick = {
|
||||
selectedFilters = emptySet()
|
||||
selectedStatus = emptySet()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Keine")
|
||||
Text("Keine", maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,14 +354,14 @@ fun DamageFilterDialog(
|
||||
onClick = {
|
||||
val startDateToApply = if (useDateFilter) startDate else null
|
||||
val endDateToApply = if (useDateFilter) endDate else null
|
||||
onApplyFilter(selectedFilters, startDateToApply, endDateToApply)
|
||||
onApplyFilter(selectedFilters, selectedStatus, startDateToApply, endDateToApply)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
selectedFilters.isEmpty() && !useDateFilter -> "Alle anzeigen"
|
||||
selectedFilters.isEmpty() && selectedStatus.isEmpty() && !useDateFilter -> "Alle anzeigen"
|
||||
else -> "Filter anwenden"
|
||||
}
|
||||
)
|
||||
@@ -313,25 +373,34 @@ fun DamageFilterDialog(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
selectedTypes: Set<String>,
|
||||
selectedStatus: Set<String>,
|
||||
startDate: LocalDate? = null,
|
||||
endDate: LocalDate? = null
|
||||
): Boolean {
|
||||
val statusTypes = listOf("neu", "in Bearbeitung", "Schaden behoben")
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val whereClauses = mutableListOf<String>()
|
||||
|
||||
// ===== TYP-FILTER =====
|
||||
// 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) {
|
||||
val typeList = selectedTypes.joinToString("', '", "'", "'")
|
||||
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 =====
|
||||
// Feldname: EditDate
|
||||
if (startDate != null || endDate != null) {
|
||||
@@ -355,26 +424,32 @@ suspend fun MapViewModel.applyDamageFilter(
|
||||
|
||||
featureLayer.definitionExpression = 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) {
|
||||
snackBarMessage = when {
|
||||
selectedTypes.isEmpty() && (startDate == null && endDate == null) -> {
|
||||
"Alle Schäden werden angezeigt"
|
||||
snackBarMessage = buildString {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
// 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)
|
||||
"Datumsfilter: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}"
|
||||
|
||||
// Status-Info
|
||||
if (selectedStatus.size < statusTypes.size) {
|
||||
parts.add("${selectedStatus.size} Status")
|
||||
}
|
||||
selectedTypes.size < DAMAGE_TYPES.size && (startDate == null && endDate == null) -> {
|
||||
// NUR Typ-Filter aktiv
|
||||
"${selectedTypes.size} Typ(en) gefiltert"
|
||||
|
||||
// Datum-Info
|
||||
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
|
||||
val typeInfo = "${selectedTypes.size} Typ(en)"
|
||||
val dateInfo = "Datum: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}"
|
||||
"$typeInfo | $dateInfo"
|
||||
|
||||
if (parts.isEmpty()) {
|
||||
append("Alle Schäden werden angezeigt")
|
||||
} else {
|
||||
append("Filter: ${parts.joinToString(" | ")}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
Reference in New Issue
Block a user