Compare commits

..

20 Commits

Author SHA1 Message Date
5021becf5b docs/README.md aktualisiert 2026-02-14 11:55:25 +00:00
7b5abed587 - README hinzugefügt
- BugFixing eigenen Standort.
2026-02-14 12:52:03 +01:00
3b81269a57 Merge remote-tracking branch 'origin/main' 2026-02-14 10:55:09 +01:00
a2866cc268 symbole angepasst
unused fun gelöscht
2026-02-14 10:54:36 +01:00
8342723e09 - Dokumentation
- Bereinigung von Code
2026-02-13 10:33:21 +01:00
48802606e8 - Camerafunktionalität dokumentiert
- Aufräumarbeiten
2026-02-09 22:20:59 +01:00
7d6bf0fdd7 - Filtern nach Status
- App Meldungen über nahe Schäden erhalten.
2026-02-08 12:35:11 +01:00
7781551e02 status hinzugefügt
regelbasiertes styling
architektur überarbeitet
2026-02-06 15:34:39 +01:00
8eeeb2ce99 - Sortieren nach Relevanz in Schadensliste 2026-01-30 12:56:14 +01:00
d05da838a8 - Filterfunktion erweitert mit Filtern nach Datum 2026-01-29 22:38:02 +01:00
fbf677c23a - Schadensliste wurde implementiert 2026-01-26 13:54:14 +01:00
05426b687c - Bewertungsystem
- Filtern nach Schäden
- Docs Ordner mit Markdown dummies gefüllt.
2026-01-21 21:37:58 +01:00
33e95641d0 FeatureInfo Widget hinzugefügt 2026-01-19 21:40:57 +01:00
c9b2b262a8 OverlayShell in Widget ausgelagert, zur wiederverwendbarkeit 2026-01-18 18:08:42 +01:00
407316a4c5 - GPS Location zu DraftReport 2026-01-18 17:24:28 +01:00
30d5a17e6e - Code bereinigt
- MapView in MapSegment ausgelagert
- Punkt kann jetzt manuell gesetzt werden
- Eingaben werden erstmal im ReportDraft gespeichert
2026-01-18 16:49:03 +01:00
97d86523ab - Code bereinigt
- MapView in MapSegment ausgelagert
2026-01-13 16:23:04 +01:00
f5ac96807c - Featurelayer ArcGIS hinzugefügt
- Textbox jetzt nutzbar
- Schadenauswahl Dropdown Menü
- Featureerstellung nimmt eigene Position und wird hinzugefügt.
2026-01-07 18:03:50 +01:00
91b885f67c Merge remote-tracking branch 'origin/main' 2026-01-06 13:07:07 +01:00
6b5ea3593b Update gitignore 2026-01-06 12:57:24 +01:00
54 changed files with 4849 additions and 558 deletions

BIN
.gitignore vendored

Binary file not shown.

6
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
</component>
</project>

View File

@@ -4,14 +4,22 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-12-19T16:43:39.005516700Z">
<DropdownSelection timestamp="2026-02-06T10:29:08.485527Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=N0AA003656K80600629" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
<DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState>
</selectionStates>
</component>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@@ -58,6 +58,8 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
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)
@@ -74,4 +76,8 @@ dependencies {
implementation(platform(libs.arcgis.maps.kotlin.toolkit.bom))
implementation(libs.arcgis.maps.kotlin.toolkit.geoview.compose)
implementation(libs.arcgis.maps.kotlin.toolkit.authentication)
}

View File

@@ -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"

View File

@@ -1,5 +1,7 @@
package com.example.snapandsolve
import MainScreen
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -20,19 +22,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = AlbumViewModel(coroutineContext = Dispatchers.Default)
/*
Wird gebraucht um die Karte in ArcGIS anzuzeigen. Die Prüfung ob man Zugang hat oder nicht
wurde gelöscht.
*/
ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN)
enableEdgeToEdge()
setContent {
SnapAndSolveTheme {
MainScreen(application=application)
MainScreen(application=application, context=this)
}
}
}

View File

@@ -1,123 +1,95 @@
package com.example.snapandsolve
import android.Manifest
import DamageFilterDialog
import DamageListDialog
import MapViewModel
import android.app.Application
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
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 android.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Filter
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.Person
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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 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.AlbumViewState
import com.example.snapandsolve.camera.Intent
import com.example.snapandsolve.ui.theme.AppColor
import com.example.snapandsolve.ui.theme.ButtonColor
import com.example.snapandsolve.ui.theme.SideSlider
import com.example.snapandsolve.ui.theme.SliderMenuItem
import com.example.snapandsolve.ui.theme.WidgetColor
import com.example.snapandsolve.ui.theme.setupLocationDisplay
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
import kotlinx.coroutines.launch
@Composable
fun MainScreen(modifier: Modifier = Modifier, application: Application) {
fun MainScreen(modifier: Modifier = Modifier, application: Application, context: Context) {
var showReport by rememberSaveable { mutableStateOf(false) }
var sliderOpen by remember { mutableStateOf(false) }
var sliderOpen by rememberSaveable { mutableStateOf(false) }
var showSettings by rememberSaveable { mutableStateOf(false) }
val mapViewModel = remember { MapViewModel(application, context) }
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
fun openReport() {
showReport = true
sliderOpen = false
}
fun closeReport() {
showReport = false
}
fun openSettings() {
showSettings = true
sliderOpen = false
}
fun closeSettings() {
showSettings = false
}
LaunchedEffect(mapViewModel.reopenReport) {
if (mapViewModel.reopenReport) {
showReport = true
mapViewModel.consumeReopenReport()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
AppTopBar()
},
topBar = { AppTopBar() },
bottomBar = {
BottomAppBar(
modifier = Modifier.height(120.dp),
containerColor = AppColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
sliderOpen = !sliderOpen
showReport = false
},
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu",
)
}
Spacer(Modifier.weight(1f))
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false; showSettings = false }) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
}
},
floatingActionButton = {
LargeFloatingActionButton(
onClick = {
showReport = true
sliderOpen = false
mapViewModel.resetDraft()
mapViewModel.closeFeatureInfo()
openReport()
},
modifier = Modifier.offset(y = 64.dp),
containerColor = ButtonColor
@@ -127,65 +99,118 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
},
floatingActionButtonPosition = FabPosition.Center,
) { innerPadding ->
if (showSettings) {
SettingsScreen(
onBack = ::closeSettings,
mapViewModel = mapViewModel
)
} else {
ContentScreen(
modifier = Modifier.padding(innerPadding),
application,
mapViewModel = mapViewModel,
albumViewModel = albumViewModel,
showReport = showReport,
sliderOpen = sliderOpen,
onDismissReport = { showReport = false })
onDismissReport = ::closeReport,
onOpenSettings = ::openSettings
)
}
}
}
@Composable
fun ContentScreen(
modifier: Modifier = Modifier,
application: Application,
mapViewModel: MapViewModel,
albumViewModel: AlbumViewModel,
showReport: Boolean,
sliderOpen: Boolean,
onDismissReport: () -> Unit
onDismissReport: () -> Unit,
onOpenSettings: () -> Unit
) {
val mapViewModel = remember { MapViewModel(application) }
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
// ArcGIS Map erstellen
val map = remember {
createMap() //Funktion zur Erstellung der Map
}
//Standortbestimmung aus locationHelper.kt
val locationDisplay = setupLocationDisplay()
var showFilterDialog by remember { mutableStateOf(false) }
var showDamageList by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) {
// HINTERGRUND: Die Map
MapView(
// Map
MapSegment(
modifier = Modifier.fillMaxSize(),
arcGISMap = map,
locationDisplay = locationDisplay
mapViewModel = mapViewModel
)
// VORDERGRUND: Das Overlay (wenn showReport = true)
if (showReport) {
ReportOverlay(
onCancel = onDismissReport,
onAdd = { /* später */ },
viewModel = albumViewModel
// Dialog
if (showDamageList) {
DamageListDialog(
onDismiss = { showDamageList = false },
onDamageClick = { feature ->
mapViewModel.selectedFeature = feature
mapViewModel.showFeatureInfo = true
},
mapViewModel = mapViewModel
)
}
// RECHTSGRUND: Das Slider
// Report Overlay
if (showReport) {
ReportDialog(
onCancel = onDismissReport,
onClose = onDismissReport,
viewModel = albumViewModel,
mapViewModel = mapViewModel
)
}
// Feature Info Dialog
if (mapViewModel.showFeatureInfo) {
FeatureInfoDialog(
feature = mapViewModel.selectedFeature,
onDismiss = { mapViewModel.closeFeatureInfo() },
onRate = { feature, isPositive ->
coroutineScope.launch {
mapViewModel.updateFeatureRating(feature, isPositive, context)
}
}
)
}
// Filter Dialog - AKTUALISIERT mit Status-Parameter
if (showFilterDialog) {
DamageFilterDialog(
damageTypes = MapViewModel.DAMAGE_TYPES,
currentFilters = mapViewModel.getActiveFilters(),
onDismiss = { showFilterDialog = false },
onApplyFilter = { selectedTypes, selectedStatus, startDate, endDate ->
coroutineScope.launch {
mapViewModel.applyDamageFilter(selectedTypes, selectedStatus, startDate, endDate)
}
}
)
}
// Side Slider
SideSlider(visible = sliderOpen) {
Text(
"Menü",
style = MaterialTheme.typography.titleMedium,
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 = {
/* TODO */
}
onClick = { showFilterDialog = true }
)
SliderMenuItem(
text = "Schadensliste",
icon = Icons.Default.FormatListNumbered,
onClick = { showDamageList = true }
)
}
}
@@ -206,195 +231,3 @@ fun AppTopBar(
)
)
}
@Composable
fun ReportOverlay(
onCancel: () -> Unit,
onAdd: () -> Unit,
viewModel: AlbumViewModel
) {
val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState()
val currentContext = LocalContext.current
// Launcher für Bildauswahl aus Galerie
val pickImageFromAlbumLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20)
) { urls ->
viewModel.onReceive(Intent.OnFinishPickingImagesWith(currentContext, urls))
}
// Launcher für Kamera
val cameraLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { isImageSaved ->
if (isImageSaved) {
viewModel.onReceive(Intent.OnImageSavedWith(currentContext))
} else {
viewModel.onReceive(Intent.OnImageSavingCanceled)
}
}
// Launcher für Kamera-Berechtigung
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { permissionGranted ->
if (permissionGranted) {
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
} else {
viewModel.onReceive(Intent.OnPermissionDenied)
}
}
// Funktion zum Starten der Kamera (prüft Berechtigung)
fun startCamera() {
println("DEBUG: startCamera() aufgerufen")
val hasPermission = currentContext.checkSelfPermission(Manifest.permission.CAMERA) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
println("DEBUG: Hat Berechtigung? $hasPermission")
if (hasPermission) {
println("DEBUG: Erstelle tempFileUrl")
// Berechtigung bereits erteilt -> direkt tempFileUrl erstellen
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
} else {
println("DEBUG: Frage Berechtigung an")
// Berechtigung anfragen
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// Kamera starten, wenn tempFileUrl gesetzt ist
LaunchedEffect(key1 = viewState.tempFileUrl) {
viewState.tempFileUrl?.let {
cameraLauncher.launch(it)
}
}
// leichter Dim-Hintergrund
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.25f)),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.heightIn(min = 400.dp),
shape = RoundedCornerShape(24.dp),
colors = CardColors(
containerColor = WidgetColor,
contentColor = ButtonColor,
disabledContainerColor = Color.White,
disabledContentColor = Color.White
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"Schadensbeschreibung:",
color = Color.Black
)
// Kamera-Buttons (über der weißen Box)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = {
startCamera()
},
modifier = Modifier.weight(1f)
) {
Text(text = "Foto aufnehmen")
}
Button(
onClick = {
pickImageFromAlbumLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.weight(1f)
) {
Text(text = "Aus Galerie")
}
}
// Die weiße Textfeld Box
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.background(Color.White, RoundedCornerShape(12.dp))
)
// Grid für ausgewählte Bilder (außerhalb der Box, aber in der Card)
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
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedButton(
onClick = onCancel,
colors = ButtonColors(
containerColor = ButtonColor,
contentColor = Color.White,
disabledContainerColor = Color.White,
disabledContentColor = Color.White
)
) {
Text(
"Abbrechen",
color = Color.Black
)
}
Button(onClick = onAdd) { Text("Hinzufügen") }
}
}
}
}
}
fun createMap(): ArcGISMap {
return ArcGISMap(BasemapStyle.ArcGISTopographic).apply {
initialViewpoint = Viewpoint(
53.14,
8.20,
20000.0)
}
}

View File

@@ -0,0 +1,122 @@
package com.example.snapandsolve
import MapViewModel
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.arcgismaps.ApiKey
import com.arcgismaps.ArcGISEnvironment
import com.arcgismaps.location.LocationDisplayAutoPanMode
import com.arcgismaps.toolkit.geoviewcompose.MapView
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
import kotlinx.coroutines.launch
@Composable
fun MapSegment(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
ArcGISEnvironment.applicationContext = context.applicationContext
ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN)
// val snackbarHostState = remember { SnackbarHostState() }
val locationDisplay = rememberLocationDisplay().apply {
setAutoPanMode(LocationDisplayAutoPanMode.Off)
}
LaunchedEffect(locationDisplay) {
mapViewModel.locationDisplay = locationDisplay
}
if (checkPermissions(context)) {
// Permissions are already granted.
LaunchedEffect(Unit) {
locationDisplay.dataSource.start()
}
} else {
RequestPermissions(
context = context,
onPermissionsGranted = {
coroutineScope.launch {
locationDisplay.dataSource.start()
}
}
)
}
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MapView(
modifier = Modifier.weight(90f),
arcGISMap = mapViewModel.map,
locationDisplay = locationDisplay,
mapViewProxy = mapViewModel.mapViewProxy,
onSingleTapConfirmed = mapViewModel::onTap, // Ganz normal
graphicsOverlays = listOf(mapViewModel.tempOverlay)
)
}
}
fun checkPermissions(context: Context): Boolean {
// Check permissions to see if both permissions are granted.
// Coarse location permission.
val permissionCheckCoarseLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
// Fine location permission.
val permissionCheckFineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
return permissionCheckCoarseLocation && permissionCheckFineLocation
}
fun showError(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
@Composable
fun RequestPermissions(context: Context, onPermissionsGranted: () -> Unit) {
// Create an activity result launcher using permissions contract and handle the result.
val activityResultLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
// Check if both fine & coarse location permissions are true.
if (permissions.all { it.value }) {
onPermissionsGranted()
} else {
showError(context, "Location permissions were denied")
}
}
LaunchedEffect(Unit) {
activityResultLauncher.launch(
// Request both fine and coarse location permissions.
arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
)
}
}

View File

@@ -1,64 +1,473 @@
package com.example.snapandsolve
import android.app.Application
import android.content.Context
import android.graphics.Bitmap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.arcgismaps.LoadStatus
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.data.CodedValueDomain
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
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.mapping.layers.FeatureLayer
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle
import com.arcgismaps.mapping.view.Graphic
import com.arcgismaps.mapping.view.GraphicsOverlay
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) {
class MapViewModel(application: Application, context: Context) : AndroidViewModel(application) {
companion object {
// Zentrale Definition der Schadenstypen
val DAMAGE_TYPES = listOf(
"Straße",
"Gehweg",
"Fahrradweg",
"Beleuchtung",
"Sonstiges"
)
}
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
}
/*
ALLES UNTER DIESEM KOMMENTAR WIRD NICHT GENUTZT. Aber EVENTUELL nötig zum einbinden von
Layer und Features.
*/
// Hold a reference to the selected feature.
var duplicateDamages by mutableStateOf<List<DamageWithDistance>>(emptyList())
private set
var selectedOperation by mutableStateOf(FeatureOperationType.DEFAULT)
var reopenReport by mutableStateOf(false)
private set
var showFeatureInfo by mutableStateOf(false)
public set
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
val mapViewProxy = MapViewProxy()
//var currentFeatureOperation by mutableStateOf(FeatureOperationType.CREATE)
var reportDraft by mutableStateOf(ReportDraft())
private set
lateinit var featureLayer: FeatureLayer
// Create a snackbar message to display the result of feature operations.
var snackBarMessage: String by mutableStateOf("")
lateinit var serviceFeatureTable: ServiceFeatureTable
var currentDamageType by mutableStateOf("")
// The list of damage types to update the feature attribute.
var damageTypeList: List<String> = mutableListOf()
var locationDisplay: LocationDisplay? = null
// Create a red circle simple marker symbol.
val redCircleSymbol = SimpleMarkerSymbol(
style = SimpleMarkerSymbolStyle.Circle,
color = com.arcgismaps.Color.red,
size = 10.0f
)
var pointGraphic = Graphic(null, redCircleSymbol)
val tempOverlay = GraphicsOverlay()
init {
tempOverlay.graphics.add(pointGraphic)
viewModelScope.launch {
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0")
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/251120_StrassenSchaeden/FeatureServer/0")
serviceFeatureTable.load().onSuccess {
// Get the field from the feature table that will be updated.
val typeDamageField = serviceFeatureTable.fields.first { it.name == "Typ" }
// Get the coded value domain for the field.
val attributeDomain = typeDamageField.domain as CodedValueDomain
// Add the damage types to the list.
attributeDomain.codedValues.forEach {
val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" }
val attributeDomain = typeDamageField?.domain as? CodedValueDomain
attributeDomain?.codedValues?.forEach {
damageTypeList += it.name
}
println("DEBUG: ServiceFeatureTable erfolgreich geladen")
// ===== DEBUG: Alle verfügbaren Felder ausgeben =====
println("DEBUG: Verfügbare Felder in ServiceFeatureTable:")
serviceFeatureTable.fields.forEach { field ->
println(" - ${field.name}")
}
println("DEBUG: Ende Feldliste")
}.onFailure {
println("DEBUG: Fehler beim Laden der Tabelle: ${it.message}")
}
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
featureLayer.renderer = createTypStatusRenderer(context)
map.operationalLayers.add(featureLayer)
// ===== DEBUG: Felder nach dem Hinzufügen zur Map =====
featureLayer.load().onSuccess {
println("DEBUG: FeatureLayer erfolgreich geladen")
val table = featureLayer.featureTable
if (table != null) {
println("DEBUG: Verfügbare Felder im FeatureLayer:")
table.fields.forEach { field ->
println(" - ${field.name}")
}
println("DEBUG: Ende Feldliste FeatureLayer")
}
}
}
}
fun pickCurrentLocation() {
val pos = locationDisplay?.location?.value?.position
if (pos == null) {
snackBarMessage = "Kein GPS Signal. Bitte kurz warten oder Standort aktivieren."
return
}
// pos ist ggf. schon Point, aber wir erzwingen WGS84
val pointWgs84 = if (pos.spatialReference == SpatialReference.wgs84()) {
pos
} else {
GeometryEngine.projectOrNull(pos, SpatialReference.wgs84()) as Point
}
// ===== KEIN Z hinzufügen! =====
updateReportDraft { copy(point = pointWgs84) }
pointGraphic.geometry = pointWgs84
println("DEBUG pickCurrentLocation: point = $pointWgs84")
snackBarMessage = "Position aus GPS gesetzt."
}
private suspend fun applyEditsWithPhotos(feature: ArcGISFeature, photos: List<ImageBitmap>) {
println("DEBUG applyEditsWithPhotos: START")
println("DEBUG applyEditsWithPhotos: photos.size = ${photos.size}")
// ERST: Feature zum Server senden
serviceFeatureTable.applyEdits().onSuccess { editResults ->
println("DEBUG applyEditsWithPhotos: applyEdits SUCCESS")
val result = editResults.firstOrNull()
if (result != null && result.error == null) {
val serverObjectId = result.objectId
println("DEBUG: Server-Erfolg! ObjectID: $serverObjectId")
if (photos.isNotEmpty()) {
// Feature vom Server neu laden
val queryParameters = QueryParameters().apply {
objectIds.add(serverObjectId)
}
serviceFeatureTable.queryFeatures(queryParameters).onSuccess { queryResult ->
val fetchedFeature = queryResult.firstOrNull() as? ArcGISFeature
if (fetchedFeature != null) {
println("DEBUG: Feature neu geladen, füge Fotos hinzu...")
addPhotosToFeature(fetchedFeature, photos, serverObjectId)
} else {
println("DEBUG: Feature nach Query nicht gefunden")
snackBarMessage = "Feature erstellt (ID: $serverObjectId), aber Fotos konnten nicht hinzugefügt werden."
}
}.onFailure { error ->
println("DEBUG: Query fehlgeschlagen: ${error.message}")
snackBarMessage = "Feature erstellt, aber Fotos konnten nicht zugeordnet werden."
}
} else {
println("DEBUG: Keine Fotos, Feature erfolgreich erstellt!")
snackBarMessage = "Erfolgreich gemeldet! ID: $serverObjectId"
}
} else {
println("DEBUG: Server-Fehler bei applyEdits: ${result?.error?.message}")
snackBarMessage = "Serverfehler: ${result?.error?.message}"
}
}.onFailure { error ->
println("DEBUG: applyEdits total fehlgeschlagen: ${error.message}")
error.printStackTrace()
snackBarMessage = "Senden fehlgeschlagen: ${error.message}"
}
}
private suspend fun addPhotosToFeature(
feature: ArcGISFeature,
photos: List<ImageBitmap>,
objectId: Long?
) {
println("DEBUG: Füge ${photos.size} Fotos hinzu...")
photos.forEachIndexed { index, imageBitmap ->
try {
val byteArray = imageBitmapToByteArray(imageBitmap)
feature.addAttachment(
name = "photo_$index.jpg",
contentType = "image/jpeg",
data = byteArray
)
} catch (e: Exception) {
println("DEBUG: Attachment-Fehler bei Foto $index: ${e.message}")
}
}
serviceFeatureTable.updateFeature(feature).onSuccess {
println("DEBUG: Feature mit Anhängen aktualisiert. Finales applyEdits...")
serviceFeatureTable.applyEdits().onSuccess {
snackBarMessage = "Gespeichert mit ${photos.size} Fotos! ID: $objectId"
}
}.onFailure {
println("DEBUG: updateFeature für Anhänge fehlgeschlagen: ${it.message}")
}
}
private fun imageBitmapToByteArray(imageBitmap: ImageBitmap): ByteArray {
val stream = ByteArrayOutputStream()
imageBitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.JPEG, 80, stream)
return stream.toByteArray()
}
fun onTap(singleTapConfirmedEvent: SingleTapConfirmedEvent) {
if (featureLayer.loadStatus.value != LoadStatus.Loaded) {
snackBarMessage = "Layer not loaded!"
return
}
when (selectedOperation) {
FeatureOperationType.DEFAULT -> selectFeatureAt(singleTapConfirmedEvent.screenCoordinate)
FeatureOperationType.UPDATE_ATTRIBUTE -> selectFeatureForAttributeEditAt(singleTapConfirmedEvent.screenCoordinate)
FeatureOperationType.UPDATE_GEOMETRY -> updateFeatureGeometryAt(singleTapConfirmedEvent.screenCoordinate)
FeatureOperationType.PICK_REPORT_LOCATION -> pickReportLocation(singleTapConfirmedEvent.screenCoordinate)
}
}
private fun selectFeatureForAttributeEditAt(screenCoordinate: ScreenCoordinate) {
featureLayer?.let { featureLayer ->
// Clear any existing selection.
featureLayer.clearSelection()
selectedFeature = null
viewModelScope.launch {
// Determine if a user tapped on a feature.
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
// Get the identified feature.
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
identifiedFeature?.let {
val currentAttributeValue = it.attributes["typ"] as String
currentDamageType = currentAttributeValue
selectedFeature = it.also {
featureLayer.selectFeature(it)
}
} ?: run {
// Reset damage type if no feature identified.
currentDamageType = ""
}
}
}
}
}
private fun updateFeatureGeometryAt(screenCoordinate: ScreenCoordinate) {
featureLayer?.let { featureLayer ->
when (selectedFeature) {
// When no feature is selected.
null -> {
viewModelScope.launch {
// Determine if a user tapped on a feature.
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
// Get the identified feature.
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
identifiedFeature?.let {
selectedFeature = it.also {
featureLayer.selectFeature(it)
}
}
}
}
}
else -> {
mapViewProxy.screenToLocationOrNull(screenCoordinate)?.let { mapPoint ->
// Normalize the point - needed when the tapped location is over the international date line.
val destinationPoint = GeometryEngine.normalizeCentralMeridian(mapPoint)
viewModelScope.launch {
selectedFeature?.let { selectedFeature ->
// Load the feature.
selectedFeature.load().onSuccess {
// Update the geometry of the selected feature.
selectedFeature.geometry = destinationPoint
// Apply the edit to the feature table.
serviceFeatureTable?.updateFeature(selectedFeature)
// Push the update to the service with the service geodatabase.
serviceFeatureTable?.applyEdits()?.onSuccess {
snackBarMessage = "Moved feature ${selectedFeature.attributes["objectid"]}"
}?.onFailure {
snackBarMessage =
"Failed to move feature ${selectedFeature.attributes["objectid"]}"
}
}
}
}
}
}
}
}
}
private fun pickReportLocation(screen: ScreenCoordinate) {
val mapPoint = mapViewProxy.screenToLocationOrNull(screen)
val normalized = mapPoint?.let { GeometryEngine.normalizeCentralMeridian(it) as? Point }
if (normalized != null) {
// ===== KEIN Z hinzufügen! =====
reportDraft = reportDraft.copy(point = normalized)
pointGraphic.geometry = normalized
reopenReport = true
println("DEBUG pickReportLocation: point = $normalized")
snackBarMessage = "Position gesetzt."
} else {
snackBarMessage = "Position konnte nicht gesetzt werden."
}
selectedOperation = FeatureOperationType.DEFAULT
}
fun startPickReportLocation() {
selectedOperation = FeatureOperationType.PICK_REPORT_LOCATION
snackBarMessage = "Tippe auf die Karte, um die Position zu setzen."
}
fun consumeReopenReport() {
reopenReport = false
}
fun resetDraft() {
reportDraft = ReportDraft()
pointGraphic.geometry = null
selectedOperation = FeatureOperationType.DEFAULT
}
fun updateReportDraft(update: ReportDraft.() -> ReportDraft) {
reportDraft = reportDraft.update()
}
fun submitDraftToLayer() {
val draft = reportDraft
if (!draft.isValid) {
snackBarMessage = "Bitte Beschreibung, Typ, Position und Fotos setzen."
return
}
viewModelScope.launch {
try {
println("DEBUG: Creating feature...")
// ===== FIX: Point OHNE Z erstellen =====
val point2D = if (draft.point != null) {
Point(
x = draft.point.x,
y = draft.point.y,
spatialReference = draft.point.spatialReference
)
} else {
null
}
println("DEBUG: Point 2D created: $point2D (hasZ = ${point2D?.hasZ})")
// 1) Feature lokal erstellen MIT 2D-Point
val feature = serviceFeatureTable.createFeature().apply {
geometry = point2D // ← OHNE Z!
attributes["Beschreibung"] = draft.beschreibung
attributes["Typ"] = draft.typ
attributes["status"] = draft.status
}
println("DEBUG: Adding feature to table...")
// 2) Feature zur Tabelle hinzufügen
serviceFeatureTable.addFeature(feature).onSuccess {
println("DEBUG: addFeature SUCCESS, calling applyEditsWithPhotos...")
// 3) JETZT ERST applyEdits mit Fotos
applyEditsWithPhotos(feature as ArcGISFeature, draft.photos)
// 4) Draft zurücksetzen
resetDraft()
}.onFailure { error ->
println("DEBUG: addFeature FAILED: ${error.message}")
snackBarMessage = "Fehler beim Hinzufügen: ${error.message}"
}
} catch (e: Exception) {
println("DEBUG: Exception in submitDraftToLayer: ${e.message}")
e.printStackTrace()
snackBarMessage = "Fehler: ${e.message}"
}
}
}
fun selectFeatureAt(screenCoordinate: ScreenCoordinate) {
featureLayer?.let { featureLayer ->
// Clear any existing selection.
featureLayer.clearSelection()
selectedFeature = null
viewModelScope.launch {
// Determine if a user tapped on a feature.
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess {
identifyResult ->
// Get the identified feature.
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
identifiedFeature?.let {
selectedFeature = it.also {
featureLayer.selectFeature(it)
showFeatureInfo = true
}
}
}
}
}
}
fun closeFeatureInfo() {
showFeatureInfo = false
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()
}
fun getFeatureTable(): ServiceFeatureTable? {
return featureLayer?.featureTable as? ServiceFeatureTable
}
}
enum class FeatureOperationType(val operationName: String, val instruction: String) {
DEFAULT("Default", ""),
UPDATE_ATTRIBUTE("Update attribute", "Select an existing feature to edit its attribute."),
UPDATE_GEOMETRY("Update geometry", "Select an existing feature and tap the map to move it to a new position."),
PICK_REPORT_LOCATION("Pick report location", "Tippe auf die Karte, um die Position zu setzen."),
}
data class ReportDraft(
val beschreibung: String = "",
val typ: String = "Schadenstyp wählen...",
val photos: List<ImageBitmap> = emptyList(),
val point: Point? = null,
val status: String = "neu"
) {
val isValid: Boolean
get() =
beschreibung.isNotBlank() &&
typ != "Schadenstyp wählen..." &&
point != null &&
photos.isNotEmpty()
}

View File

@@ -1,119 +0,0 @@
package com.example.snapandsolve.camera
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.snapandsolve.BuildConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.File
import kotlin.coroutines.CoroutineContext
/**
* This variant inherits from [AndroidViewModel] and has access to the application context
*/
class AlbumAndroidViewModel(private val appContext: Application,
private val coroutineContext: CoroutineContext
): AndroidViewModel(appContext) {
//region View State
private val _albumViewState: MutableStateFlow<AlbumViewState> = MutableStateFlow(
AlbumViewState()
)
val viewStateFlow: StateFlow<AlbumViewState>
get() = _albumViewState
//endregion
fun onEvent(intent: Intent) = viewModelScope.launch(coroutineContext) {
when(intent) {
is Intent.OnPermissionGranted -> {
// Create an empty image file in the app's cache directory
val file = File.createTempFile(
"temp_image_file_", /* prefix */
".jpg", /* suffix */
appContext.cacheDir /* cache directory */
)
// Create sandboxed url for this temp file - needed for the camera API
val uri = FileProvider.getUriForFile(appContext,
"${BuildConfig.APPLICATION_ID}.provider",
file
)
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = uri)
}
is Intent.OnPermissionDenied -> {
// maybe log the permission denial event
println("User did not grant permission to use the camera")
}
is Intent.OnFinishPickingImages -> {
if (intent.imageUrls.isNotEmpty()) {
// Handle picked images
val newImages = mutableListOf<ImageBitmap>()
for (eachImageUrl in intent.imageUrls) {
val inputStream = appContext.contentResolver.openInputStream(eachImageUrl)
val bytes = inputStream?.readBytes()
inputStream?.close()
if (bytes != null) {
val bitmapOptions = BitmapFactory.Options()
bitmapOptions.inMutable = true
val bitmap: Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bitmapOptions)
val imageBitmap = bitmap.asImageBitmap()
newImages.add(imageBitmap)
} else {
// error reading the bytes from the image url
println("The image that was picked could not be read from the device at this url: $eachImageUrl")
}
}
val currentViewState = _albumViewState.value
val newCopy = currentViewState.copy(
selectedPictures = (currentViewState.selectedPictures + newImages),
tempFileUrl = null
)
_albumViewState.value = newCopy
} else {
// user did not pick anything
}
}
is Intent.OnImageSaved -> {
val tempImageUrl = _albumViewState.value.tempFileUrl
if (tempImageUrl != null) {
val source: ImageDecoder.Source = ImageDecoder.createSource(appContext.contentResolver, tempImageUrl)
val currentPictures = _albumViewState.value.selectedPictures.toMutableList()
currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap())
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null,
selectedPictures = currentPictures)
}
}
is Intent.OnImageSavingCanceled -> {
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null)
}
is Intent.OnFinishPickingImagesWith -> {
// unnecessary in this viewmodel variant
}
is Intent.OnPermissionGrantedWith -> {
// unnecessary in this viewmodel variant
}
is Intent.OnImageSavedWith -> {
// unnecessary in this viewmodel variant
}
}
}
}

View File

@@ -1,28 +1,39 @@
package com.example.snapandsolve.camera
import android.content.Context
import android.net.Uri
/*
Das sind die Funktionen beim Drücken der Knöpfe. Übersichtlicher wäre es sie direkt mit den
Knöpfen in der ViewModel zu platzieren. AlbumEvents als name ist vielleicht unglücklich gewählt.
*/
/**
* User generated events that can be triggered from the UI.
* Benutzer-Aktionen für das Album/Kamera-System.
*/
sealed class Intent {
data object OnPermissionGranted: Intent()
/**
* Kamera-Permission wurde erteilt.
* @param compositionContext Android Context für Dateizugriff
*/
data class OnPermissionGrantedWith(val compositionContext: Context): Intent()
/**
* Kamera-Permission wurde verweigert.
*/
data object OnPermissionDenied: Intent()
data object OnImageSaved: Intent()
/**
* Kamera-Aufnahme wurde gespeichert.
* @param compositionContext Android Context für Dateizugriff
*/
data class OnImageSavedWith(val compositionContext: Context): Intent()
/**
* Kamera-Aufnahme wurde abgebrochen.
*/
data object OnImageSavingCanceled: Intent()
data class OnFinishPickingImages(val imageUrls: List<Uri>): Intent()
data class OnFinishPickingImagesWith(val compositionContext: Context, val imageUrls: List<Uri>): Intent()
/**
* Bilder aus Galerie wurden ausgewählt.
* @param compositionContext Android Context für Dateizugriff
* @param imageUrls Liste der ausgewählten Bild-URIs
*/
data class OnFinishPickingImagesWith(
val compositionContext: Context,
val imageUrls: List<Uri>
): Intent()
}

View File

@@ -1,6 +1,6 @@
package com.example.snapandsolve.camera
import Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
@@ -10,25 +10,33 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.snapandsolve.BuildConfig
import com.example.snapandsolve.camera.AlbumViewState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.File
import kotlin.coroutines.CoroutineContext
class AlbumViewModel(private val coroutineContext: CoroutineContext
): ViewModel() {
/**
* ViewModel für Bild-Verwaltung (Kamera + Galerie).
* Verwaltet ausgewählte Bilder und temporäre Kamera-Dateien.
*/
class AlbumViewModel(private val coroutineContext: CoroutineContext) : ViewModel() {
//region View State
private val _albumViewState: MutableStateFlow<AlbumViewState> = MutableStateFlow(
AlbumViewState()
)
/**
* Observable State für UI-Komponenten.
*/
val viewStateFlow: StateFlow<AlbumViewState>
get() = _albumViewState
//endregion
// region Intents
/**
* Verarbeitet Benutzer-Aktionen.
* @param intent Die zu verarbeitende Aktion
*/
fun onReceive(intent: Intent) = viewModelScope.launch(coroutineContext) {
when (intent) {
is Intent.OnPermissionGrantedWith -> {
@@ -40,7 +48,8 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
)
println("DEBUG: TempFile erstellt: ${tempFile.absolutePath}")
val uri = FileProvider.getUriForFile(intent.compositionContext,
val uri = FileProvider.getUriForFile(
intent.compositionContext,
"${BuildConfig.APPLICATION_ID}.provider",
tempFile
)
@@ -50,13 +59,11 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
}
is Intent.OnPermissionDenied -> {
// maybe log the permission denial event
println("User did not grant permission to use the camera")
}
is Intent.OnFinishPickingImagesWith -> {
if (intent.imageUrls.isNotEmpty()) {
// Handle picked images
val newImages = mutableListOf<ImageBitmap>()
for (eachImageUrl in intent.imageUrls) {
val inputStream = intent.compositionContext.contentResolver.openInputStream(eachImageUrl)
@@ -69,7 +76,6 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
val bitmap: Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bitmapOptions)
newImages.add(bitmap.asImageBitmap())
} else {
// error reading the bytes from the image url
println("The image that was picked could not be read from the device at this url: $eachImageUrl")
}
}
@@ -80,40 +86,37 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
tempFileUrl = null
)
_albumViewState.value = newCopy
} else {
// user did not pick anything
}
}
is Intent.OnImageSavedWith -> {
val tempImageUrl = _albumViewState.value.tempFileUrl
if (tempImageUrl != null) {
val source = ImageDecoder.createSource(intent.compositionContext.contentResolver, tempImageUrl)
val source = ImageDecoder.createSource(
intent.compositionContext.contentResolver,
tempImageUrl
)
val currentPictures = _albumViewState.value.selectedPictures.toMutableList()
currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap())
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null,
selectedPictures = currentPictures)
_albumViewState.value = _albumViewState.value.copy(
tempFileUrl = null,
selectedPictures = currentPictures
)
}
}
is Intent.OnImageSavingCanceled -> {
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null)
}
is Intent.OnPermissionGranted -> {
// unnecessary in this viewmodel variant
}
}
is Intent.OnFinishPickingImages -> {
// unnecessary in this viewmodel variant
}
is Intent.OnImageSaved -> {
// unnecessary in this viewmodel variant
/**
* Löscht alle ausgewählten Bilder aus dem State.
*/
fun clearSelection() {
_albumViewState.value = _albumViewState.value.copy(selectedPictures = emptyList())
}
}
}
// endregion
}

View File

@@ -8,12 +8,12 @@ import androidx.compose.ui.graphics.ImageBitmap
*/
data class AlbumViewState(
/**
* holds the URL of the temporary file which stores the image taken by the camera.
* Speichert die URL der temporären Datei des Kamerabildes.
*/
val tempFileUrl: Uri? = null,
/**
* holds the list of images taken by camera or selected pictures from the gallery.
* Speichert eine Liste der Bilder, die entweder über die Kamera aufgenommen oder aus der Galerie ausgewählt wurden.
*/
val selectedPictures: List<ImageBitmap> = emptyList(),
)

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}")
// Beide Punkte ins gleiche Koordinatensystem bringen von UTM ins WGS
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) {
val kategorie = feature.attributes["Kategorie"]?.toString()
?: feature.attributes["kategorie"]?.toString()
?: feature.attributes["Category"]?.toString()
?: feature.attributes["category"]?.toString()
?: "Straßenschaden" // Fallback
val beschreibung = feature.attributes["Beschreibung"]?.toString()
?: feature.attributes["beschreibung"]?.toString()
?: feature.attributes["Description"]?.toString()
?: feature.attributes["description"]?.toString()
val distanceText = String.format("%.0f", distance)
// 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

@@ -0,0 +1,475 @@
import MapViewModel.Companion.DAMAGE_TYPES
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
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,
emoji: String = ""
) {
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 = emoji,
style = MaterialTheme.typography.headlineSmall
)
}
}
/**
* Dialog für Schaden-Filter
* 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>, Set<String>, LocalDate?, LocalDate?) -> Unit
) {
// Status-Liste
val statusTypes = listOf("neu", "in Bearbeitung", "Schaden behoben")
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) }
var endDate by remember { mutableStateOf<LocalDate?>(null) }
var useDateFilter by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.fillMaxHeight(0.85f),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Schäden filtern",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Schließen")
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Scroll-Content
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
// Filtertyp
Text(
text = "Schadenstypen:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
// Info-Text
Text(
text = "Wähle die Schadenstypen aus, die angezeigt werden sollen:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
// Filter-Liste
damageTypes.forEach { type ->
FilterCheckboxItem(
label = type,
isChecked = selectedFilters.contains(type),
onCheckedChange = { isChecked ->
selectedFilters = if (isChecked) {
selectedFilters + type
} else {
selectedFilters - type
}
},
emoji = getEmojiForType(type)
)
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Filter nach Status
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 -> ""
}
)
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Filter nach Datum
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = useDateFilter,
onCheckedChange = { useDateFilter = it }
)
Text(
text = "Nach Datum filtern",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp)
)
}
// Datums-Eingabe (nur wenn aktiviert)
if (useDateFilter) {
// Von-Datum
Text(
text = "Von:",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp)
)
OutlinedTextField(
value = startDateString,
onValueChange = { newValue ->
startDateString = newValue
startDate = try {
if (newValue.length == 10) { // dd.MM.yyyy format
LocalDate.parse(
newValue,
DateTimeFormatter.ofPattern("dd.MM.yyyy")
)
} else {
null
}
} catch (e: Exception) {
null
}
},
placeholder = { Text("dd.MM.yyyy") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
singleLine = true
)
// Bis-Datum
Text(
text = "Bis:",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp)
)
OutlinedTextField(
value = endDateString,
onValueChange = { newValue ->
endDateString = newValue
endDate = try {
if (newValue.length == 10) { // dd.MM.yyyy format
LocalDate.parse(
newValue,
DateTimeFormatter.ofPattern("dd.MM.yyyy")
)
} else {
null
}
} catch (e: Exception) {
null
}
},
placeholder = { Text("dd.MM.yyyy") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Text(
text = "Format: dd.MM.yyyy (z.B. 15.01.2024)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(top = 8.dp, bottom = 12.dp)
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Info über aktive Filter
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) {
append(" | Datum: ${startDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"} - ${endDate?.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) ?: "?"}")
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Alle Typen
OutlinedButton(
onClick = {
selectedFilters = damageTypes.toSet()
selectedStatus = statusTypes.toSet()
},
modifier = Modifier.weight(1f)
) {
Text("Alle", maxLines = 1)
}
// Keine Typen
OutlinedButton(
onClick = {
selectedFilters = emptySet()
selectedStatus = emptySet()
},
modifier = Modifier.weight(1f)
) {
Text("Keine", maxLines = 1)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Anwenden Button
Button(
onClick = {
val startDateToApply = if (useDateFilter) startDate else null
val endDateToApply = if (useDateFilter) endDate else null
onApplyFilter(selectedFilters, selectedStatus, startDateToApply, endDateToApply)
onDismiss()
},
modifier = Modifier.fillMaxWidth()
) {
Text(
when {
selectedFilters.isEmpty() && selectedStatus.isEmpty() && !useDateFilter -> "Alle anzeigen"
else -> "Filter anwenden"
}
)
}
}
}
}
}
/**
* Erweiterungs-Funktion für MapViewModel
* Wendet Filter auf den FeatureLayer an
*/
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>()
// Filter Typ
if (selectedTypes.isNotEmpty() && selectedTypes.size < DAMAGE_TYPES.size) {
val typeList = selectedTypes.joinToString("', '", "'", "'")
whereClauses.add("Typ IN ($typeList)")
}
// Filter Status
if (selectedStatus.isNotEmpty() && selectedStatus.size < statusTypes.size) {
val statusList = selectedStatus.joinToString("', '", "'", "'")
whereClauses.add("status IN ($statusList)")
}
// Filter nach Datum
if (startDate != null || endDate != null) {
val dateField = "EditDate"
when {
startDate != null && endDate != null -> {
whereClauses.add("$dateField >= timestamp '$startDate 00:00:00' AND $dateField <= timestamp '$endDate 23:59:59'")
}
startDate != null -> {
whereClauses.add("$dateField >= timestamp '$startDate 00:00:00'")
}
endDate != null -> {
whereClauses.add("$dateField <= timestamp '$endDate 23:59:59'")
}
}
}
// Alle Klauseln kombinieren
val whereClause = whereClauses.joinToString(" AND ")
featureLayer.definitionExpression = whereClause
println("DEBUG: Filter angewendet: '$whereClause'")
println("DEBUG: Typen: ${selectedTypes.size}/${DAMAGE_TYPES.size}, Status: ${selectedStatus.size}/${statusTypes.size}, Datum aktiv: ${startDate != null || endDate != null}")
withContext(Dispatchers.Main) {
snackBarMessage = buildString {
val parts = mutableListOf<String>()
// Typ-Info
if (selectedTypes.size < DAMAGE_TYPES.size) {
parts.add("${selectedTypes.size} Typ(en)")
}
// Status-Info
if (selectedStatus.size < statusTypes.size) {
parts.add("${selectedStatus.size} Status")
}
// 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")
}
if (parts.isEmpty()) {
append("Alle Schäden werden angezeigt")
} else {
append("Filter: ${parts.joinToString(" | ")}")
}
}
}
true
} catch (e: Exception) {
println("DEBUG: Fehler beim Anwenden des Filters: ${e.message}")
e.printStackTrace()
withContext(Dispatchers.Main) {
snackBarMessage = "Fehler beim Filtern: ${e.message}"
}
false
}
}
}
/**
* Erweiterte-Funktion für MapViewModel
* Gibt die aktuell aktiven Filter zurück
*/
fun MapViewModel.getActiveFilters(): Set<String> {
val expression = featureLayer.definitionExpression
if (expression.isEmpty()) return emptySet()
val regex = "'([^']+)'".toRegex()
return regex.findAll(expression)
.map { it.groupValues[1] }
.toSet()
}

View File

@@ -0,0 +1,655 @@
import android.content.Context
import android.graphics.BitmapFactory
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.data.QueryParameters
import com.arcgismaps.geometry.Point
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Data class für einen Schaden mit Entfernung und Foto
*/
data class DamageWithDistance(
val feature: ArcGISFeature,
val distanceInMeters: Double?,
val typ: String,
val beschreibung: String,
val objectId: Long,
val rating: Int,
val photo: ImageBitmap? = null
)
/**
* Berechnet die Entfernung zwischen zwei Koordinaten mit Haversine-Formel
*/
fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // Erdradius in Metern
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
/**
* Enum für Sortierung
*/
enum class SortBy {
DISTANCE, // Nach Entfernung sortieren (nächste zuerst)
RELEVANCE // Nach Relevanz sortieren (höchste communitycounter zuerst)
}
/**
* Dialog für Schadensliste mit Entfernungs- und Relevanz-Filter
*/
@Composable
fun DamageListDialog(
onDismiss: () -> Unit,
onDamageClick: (ArcGISFeature) -> Unit,
mapViewModel: MapViewModel
) {
val context = androidx.compose.ui.platform.LocalContext.current
var damages by remember { mutableStateOf<List<DamageWithDistance>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var maxDistance by remember { mutableStateOf(1000f) }
var userLocation by remember { mutableStateOf<Point?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var sortBy by remember { mutableStateOf(SortBy.DISTANCE) }
var minRelevance by remember { mutableStateOf(0) }
LaunchedEffect(maxDistance) {
isLoading = true
errorMessage = null
val location = mapViewModel.locationDisplay?.location?.value?.position
if (location == null) {
errorMessage = "Standort nicht verfügbar. Bitte GPS aktivieren."
isLoading = false
return@LaunchedEffect
}
userLocation = location
val result = mapViewModel.loadDamagesNearby(location, maxDistance.toDouble(), context)
damages = result
isLoading = false
}
// Sortierte und gefilterte Liste
val filteredAndSortedDamages = remember(damages, sortBy, minRelevance) {
val filtered = damages.filter { it.rating >= minRelevance }
when (sortBy) {
SortBy.DISTANCE -> filtered.sortedBy { it.distanceInMeters }
SortBy.RELEVANCE -> filtered.sortedByDescending { it.rating }
}
}
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Schäden in der Nähe",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Schließen")
}
}
Divider(modifier = Modifier.padding(vertical = 12.dp))
// Filtern nach Entfernung
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.MyLocation,
contentDescription = "Umkreis",
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Umkreis: ${if (maxDistance >= 1000) "${(maxDistance / 1000).roundToInt()} km" else "${maxDistance.toInt()} m"}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = maxDistance,
onValueChange = { maxDistance = it },
valueRange = 100f..5000f,
steps = 48,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("100 m", style = MaterialTheme.typography.bodySmall)
Text("5 km", style = MaterialTheme.typography.bodySmall)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Sortieren
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Sortieren nach:",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Entfernung Button
FilterChip(
selected = sortBy == SortBy.DISTANCE,
onClick = { sortBy = SortBy.DISTANCE },
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("📍 Entfernung")
}
},
modifier = Modifier.weight(1f)
)
// Relevanz Button
FilterChip(
selected = sortBy == SortBy.RELEVANCE,
onClick = { sortBy = SortBy.RELEVANCE },
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("👥 Relevanz")
}
},
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Nach Relevanz filtern
if (sortBy == SortBy.RELEVANCE) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.TrendingUp,
contentDescription = "Relevanz",
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Min. Bewertungen: ${minRelevance}+",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = minRelevance.toFloat(),
onValueChange = { minRelevance = it.toInt() },
valueRange = 0f..50f,
steps = 49,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("0", style = MaterialTheme.typography.bodySmall)
Text("50+", style = MaterialTheme.typography.bodySmall)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
if (!isLoading && errorMessage == null) {
Text(
text = "${filteredAndSortedDamages.size} Schäden gefunden",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Content
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Lade Schäden...")
}
}
}
errorMessage != null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = errorMessage!!,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
}
}
}
filteredAndSortedDamages.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "🔍",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (damages.isEmpty()) "Keine Schäden im Umkreis" else "Keine Schäden mit dieser Bewertung",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredAndSortedDamages) { damage ->
DamageListItemWithPhoto(
damage = damage,
onClick = {
onDamageClick(damage.feature)
onDismiss()
}
)
}
}
}
}
}
}
}
}
/**
* Einzelnes Schadens-Item in der Liste mit Foto und Bewertung
*/
@Composable
fun DamageListItemWithPhoto(
damage: DamageWithDistance,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
//LINKS: Foto
Box(
modifier = Modifier
.width(100.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
) {
if (damage.photo != null) {
Image(
bitmap = damage.photo,
contentDescription = "Schadensfoto",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Text("📷", style = MaterialTheme.typography.headlineLarge)
}
}
}
// RECHTS: Info
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Typ mit Emoji
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = getEmojiForType(damage.typ),
style = MaterialTheme.typography.titleLarge
)
Text(
text = damage.typ,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// Beschreibung
Text(
text = damage.beschreibung.take(40) + if (damage.beschreibung.length > 40) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
// Info-Row (Entfernung + Bewertung)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Entfernung
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
)
}
// Bewertung / Relevanz
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
)
}
}
}
}
}
}
/**
* Erweiterte-Funktion für MapViewModel
* Lädt alle Schäden im Umkreis
*/
suspend fun MapViewModel.loadDamagesNearby(
userLocation: Point,
maxDistanceMeters: Double,
context: Context
): List<DamageWithDistance> {
return withContext(Dispatchers.IO) {
try {
println("DEBUG 1: Lade Schäden im Umkreis von ${maxDistanceMeters}m")
println("DEBUG 1.1: serviceFeatureTable = $serviceFeatureTable")
val queryParams = QueryParameters().apply {
whereClause = "1=1"
}
println("DEBUG 2: QueryParameters erstellt")
val queryResult = serviceFeatureTable?.queryFeatures(queryParams)?.getOrNull()
println("DEBUG 3: queryResult = $queryResult")
if (queryResult == null) {
println("DEBUG ERROR: queryResult ist NULL!")
return@withContext emptyList()
}
val resultList = queryResult.toList()
println("DEBUG 3.1: resultList.size = ${resultList.size}")
if (resultList.isEmpty()) {
println("DEBUG: queryResult ist LEER (0 Features)")
return@withContext emptyList()
}
val damagesWithDistance = mutableListOf<DamageWithDistance>()
var processedCount = 0
var addedCount = 0
queryResult.forEach { geoElement ->
processedCount++
println("DEBUG 4.$processedCount: Verarbeite Feature...")
val feature = geoElement as? ArcGISFeature
if (feature == null) {
println("DEBUG 4.$processedCount: Feature ist NULL, überspringe")
return@forEach
}
feature.load().getOrNull()
val featureGeometry = feature.geometry as? Point
if (featureGeometry == null) {
println("DEBUG 4.$processedCount: Geometrie ist NULL")
return@forEach
}
// Transformiere Feature-Koordinaten in die gleiche räumliche Referenz wie userLocation
val targetSpatialRef = userLocation.spatialReference
if (targetSpatialRef == null) {
println("DEBUG 4.$processedCount: User SpatialReference ist NULL")
return@forEach
}
val transformedGeometry = com.arcgismaps.geometry.GeometryEngine.projectOrNull(
featureGeometry,
targetSpatialRef
) as? Point
if (transformedGeometry == null) {
println("DEBUG 4.$processedCount: Transformation fehlgeschlagen")
return@forEach
}
// Entfernung berechnen mit Haversine-Formel
val distance = calculateDistance(
userLocation.y,
userLocation.x,
transformedGeometry.y,
transformedGeometry.x
)
println("DEBUG 4.$processedCount: Distance = $distance, max = $maxDistanceMeters")
if (distance <= maxDistanceMeters) {
addedCount++
val typ = feature.attributes["Typ"]?.toString() ?: "Unbekannt"
val beschreibung = feature.attributes["Beschreibung"]?.toString() ?: "Keine Beschreibung"
val objectId = (feature.attributes["OBJECTID"] as? Number)?.toLong() ?: 0L
val rating = (feature.attributes["communitycounter"] as? Number)?.toInt() ?: 0
// Foto laden (async)
val photo = try {
val attachments = feature.fetchAttachments().getOrNull()
val firstAttachment = attachments?.firstOrNull()
if (firstAttachment != null) {
val data = firstAttachment.fetchData().getOrNull()
if (data != null) {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
bitmap?.asImageBitmap()
} else null
} else null
} catch (e: Exception) {
null
}
damagesWithDistance.add(
DamageWithDistance(
feature = feature,
distanceInMeters = distance,
typ = typ,
beschreibung = beschreibung,
objectId = objectId,
rating = rating,
photo = photo
)
)
}
}
val sorted = damagesWithDistance.sortedBy { it.distanceInMeters }
println("DEBUG: $processedCount Features verarbeitet, $addedCount hinzugefügt")
sorted
} catch (e: Exception) {
println("DEBUG ERROR: ${e.message}")
e.printStackTrace()
withContext(Dispatchers.Main) {
Toast.makeText(context, "Fehler: ${e.message}", Toast.LENGTH_LONG).show()
}
emptyList()
}
}
}
fun formatDistance(meters: Double?): String {
return if (meters == null || meters < 1000) {
"${meters?.roundToInt()} m"
} else {
"${"%.1f".format(meters / 1000)} km"
}
}
fun getEmojiForType(typ: String): String {
return when (typ) {
"Straße" -> String(Character.toChars(0x1F6E3))
"Gehweg" -> String(Character.toChars(0x1F6B5))
"Fahrradweg" -> String(Character.toChars(0x1F6B2))
"Beleuchtung" -> String(Character.toChars(0x1F4A1))
"Sonstiges" -> String(Character.toChars(0x1F4CC))
else -> ""
}
}

View File

@@ -0,0 +1,292 @@
package com.example.snapandsolve
import MapViewModel
import android.content.Context
import android.graphics.BitmapFactory
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.arcgismaps.data.ArcGISFeature
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Composable Dialog für Feature-Details mit Bewertung
* Zeigt alle Attribute, Fotos und Bewertungsbuttons
*/
@Composable
fun FeatureInfoDialog(
feature: ArcGISFeature?,
onDismiss: () -> Unit,
onRate: (ArcGISFeature, Boolean) -> Unit
) {
if (feature == null) return
var photoBitmaps by remember { mutableStateOf<List<androidx.compose.ui.graphics.ImageBitmap>>(emptyList()) }
var isLoadingPhotos by remember { mutableStateOf(true) }
// Lade Fotos beim Öffnen
LaunchedEffect(feature) {
isLoadingPhotos = true
try {
// Lade Feature falls nötig
if (feature.loadStatus.value != com.arcgismaps.LoadStatus.Loaded) {
feature.load().getOrNull()
}
// Hole Attachments
feature.fetchAttachments().onSuccess { fetchedAttachments ->
val loadedBitmaps = mutableListOf<androidx.compose.ui.graphics.ImageBitmap>()
fetchedAttachments.forEach { attachment ->
attachment.fetchData().onSuccess { data ->
try {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
if (bitmap != null) {
loadedBitmaps.add(bitmap.asImageBitmap())
// Update die UI-Liste direkt, wenn ein Bild fertig ist
photoBitmaps = loadedBitmaps.toList()
}
} catch (e: Exception) {
println("DEBUG: Fehler beim Decodieren des Bildes: ${e.message}")
}
}
}
}
} catch (e: Exception) {
println("DEBUG: Fehler beim Laden der Attachments: ${e.message}")
} finally {
isLoadingPhotos = false
}
}
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.85f),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
.verticalScroll(rememberScrollState())
) {
// Header mit Typ
Text(
text = feature.attributes["Typ"]?.toString() ?: "Straßenschaden",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
// Beschreibung
val description = feature.attributes["Beschreibung"]?.toString()
if (!description.isNullOrEmpty()) {
Text(
text = "Beschreibung",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Fotos
if (isLoadingPhotos && photoBitmaps.isEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Suche Fotos...")
}
} else if (photoBitmaps.isNotEmpty()) {
Text(
text = "Fotos (${photoBitmaps.size})",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
photoBitmaps.forEach { bitmap ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Image(
bitmap = bitmap,
contentDescription = "Schadensfoto",
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
contentScale = ContentScale.Fit
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Community-Bewertung
val currentRating = (feature.attributes["communitycounter"] as? Number)?.toInt() ?: 0
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Community-Bewertung",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "👥",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$currentRating",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (currentRating == 1) "Bestätigung" else "Bestätigungen",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Text(
text = "Hast du diesen Schaden auch gesehen?",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 12.dp)
)
// Bewertungs-Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {//Daumen runter
Button(
onClick = {
onRate(feature, false)
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("👎", style = MaterialTheme.typography.headlineMedium)
Text("Nein", style = MaterialTheme.typography.bodyMedium)
}
}
// Daumen hoch
Button(
onClick = {
onRate(feature, true)
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("👍", style = MaterialTheme.typography.headlineMedium)
Text("Ja", style = MaterialTheme.typography.bodyMedium)
}
}
}
TextButton(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text("Schließen")
}
}
}
}
}
/**
* Erweiterungs-Funktion für MapViewModel
* Aktualisiert die Community-Bewertung eines Features
*/
suspend fun MapViewModel.updateFeatureRating(
feature: ArcGISFeature,
isPositive: Boolean,
context: Context
): Boolean {
return withContext(Dispatchers.IO) {
try {
val currentRating = (feature.attributes["communitycounter"] as? Number)?.toInt() ?: 0
val newRating = if (isPositive) currentRating + 1 else maxOf(0, currentRating - 1)
feature.attributes["communitycounter"] = newRating
serviceFeatureTable.updateFeature(feature).onSuccess {
serviceFeatureTable.applyEdits().onSuccess {
withContext(Dispatchers.Main) {
val message = if (isPositive) "✓ Schaden bestätigt!" else "✓ Bewertung verringert"
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}.onFailure { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, "Sync-Fehler: ${error.message}", Toast.LENGTH_LONG).show()
}
}
}.onFailure { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, "Update-Fehler: ${error.message}", Toast.LENGTH_LONG).show()
}
}
true
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Fehler: ${e.message}", Toast.LENGTH_LONG).show()
}
false
}
}
}

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()
// 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
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))
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,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
)
}
}
}
}
}
}

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -42,9 +42,7 @@ class LocationHelper(private val context: Context) {
/**
* Composable zum Einrichten des Location Display
*
* @param autoPanMode Wie die Karte dem Standort folgen soll (default: Recenter)
* @return LocationDisplay-Objekt, das an MapView übergeben werden kann
* Beim Auslagern wird diese Funktion nicht mehr genutzt. Das sollte gefixt werden!!!!!!!!!!!!!!
*/
@Composable
fun setupLocationDisplay(

View File

@@ -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<DamageWithDistance>,
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))
}
}
}

View File

@@ -0,0 +1,262 @@
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.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 = {
// ===== DEBUG VOR DEM SUBMIT =====
println("DEBUG Button CLICKED: isValid = ${mapViewModel.reportDraft.isValid}")
println("DEBUG Button CLICKED: point = ${mapViewModel.reportDraft.point}")
println("DEBUG Button CLICKED: photos.size = ${mapViewModel.reportDraft.photos.size}")
println("DEBUG Button CLICKED: typ = '${mapViewModel.reportDraft.typ}'")
println("DEBUG Button CLICKED: beschreibung = '${mapViewModel.reportDraft.beschreibung}'")
scope.launch {
checking = true
showDuplicateDialog = mapViewModel.isDuplicateNearby(20.0)
checking = false
if (!showDuplicateDialog) {
println("DEBUG: Calling submitDraftToLayer()...")
mapViewModel.submitDraftToLayer()
viewModel.clearSelection()
onCancel()
} else {
println("DEBUG: Duplicate gefunden, zeige Dialog")
}
}
},
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()
}
)
}
}

View File

@@ -0,0 +1,114 @@
package com.example.snapandsolve.view
import android.content.Context
import com.arcgismaps.Color
import com.arcgismaps.mapping.symbology.*
import com.example.snapandsolve.R
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
fun makeStatusTypeSymbol(
context: Context,
foregroundDrawableRes: Int,
statusColor: Color
): Symbol {
val white = Color.fromRgba(255, 255, 255, 255)
// Hintergrund: weißer Kreis + Outline in Statusfarbe
val background = SimpleMarkerSymbol(
style = SimpleMarkerSymbolStyle.Circle,
color = white,
size = 20f
).apply {
outline = SimpleLineSymbol(
style = SimpleLineSymbolStyle.Solid,
color = statusColor,
width = 2.5f
)
}
// Drawable aus Ressourcen laden
val drawable = ContextCompat.getDrawable(context, foregroundDrawableRes)
?: throw IllegalArgumentException("Drawable resource not found: $foregroundDrawableRes")
// Drawable zu Bitmap konvertieren
val bitmap = drawableToBitmap(drawable)
// Bitmap zu BitmapDrawable konvertieren
val bitmapDrawable = BitmapDrawable(context.resources, bitmap)
// Vordergrund: BitmapDrawable als PictureMarkerSymbol
val foreground = PictureMarkerSymbol.createWithImage(bitmapDrawable).apply {
width = 16f
height = 16f
}
return CompositeSymbol(listOf(background, foreground))
}
// Hilfsfunktion: Drawable zu Bitmap konvertieren
private fun drawableToBitmap(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
// Für VectorDrawables und andere Drawable-Typen
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth.takeIf { it > 0 } ?: 1,
drawable.intrinsicHeight.takeIf { it > 0 } ?: 1,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
fun createTypStatusRenderer(context: Context): UniqueValueRenderer {
val statusColorByName = 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)
)
val typeIconRes = mapOf(
"Straße" to R.drawable.motorway,
"Gehweg" to R.drawable.pedestrian,
"Fahrradweg" to R.drawable.bike,
"Beleuchtung" to R.drawable.lightbulb,
"Sonstiges" to R.drawable.pin
)
val renderer = UniqueValueRenderer().apply {
fieldNames.addAll(listOf("typ", "status"))
}
for ((typ, iconRes) in typeIconRes) {
for ((status, outlineColor) in statusColorByName) {
val label = "$typ$status"
renderer.uniqueValues.add(
UniqueValue(
label = label,
description = label,
symbol = makeStatusTypeSymbol(
context = context,
foregroundDrawableRes = iconRes,
statusColor = outlineColor
),
values = listOf(typ, status)
)
)
}
}
return renderer
}

View File

@@ -0,0 +1,78 @@
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<DamageWithDistance> {
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<DamageWithDistance>()
for (feature in result) {
val p = feature.geometry as? Point ?: continue
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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

19
docs/AlbumEvents.md Normal file
View File

@@ -0,0 +1,19 @@
## Intent (sealed class)
```kotlin
sealed class Intent
```
**Zweck:** Definition aller möglichen Benutzeraktionen im Album-System.
### Varianten
| Intent | Parameter | Beschreibung |
|--------|-----------|--------------|
| `OnPermissionGrantedWith` | `Context` | Kamera-Permission erteilt |
| `OnImageSavedWith` | `Context` | Kamera-Aufnahme gespeichert |
| `OnFinishPickingImagesWith` | `Context, List<Uri>` | Bilder aus Galerie ausgewählt |
| `OnPermissionDenied` | - | Permission verweigert |
| `OnImageSavingCanceled` | - | Kamera-Aufnahme abgebrochen |
**Deprecated:** `OnPermissionGranted`, `OnImageSaved`, `OnFinishPickingImages` (Varianten ohne Context-Parameter)

77
docs/AlbumViewModel.md Normal file
View File

@@ -0,0 +1,77 @@
# AlbumViewModel
## Übersicht
Die Klasse `AlbumViewModel` ist für die zentrale Verwaltung von Bilddaten innerhalb der App zuständig. Sie fungiert als Schnittstelle zwischen der Kamera-Hardware, der System-Galerie und der Benutzeroberfläche. Das ViewModel verarbeitet asynchrone Bildoperationen und stellt den aktuellen Status über einen reaktiven StateFlow bereit.
---
## 1. Klasse: AlbumViewModel
`class AlbumViewModel(private val coroutineContext: CoroutineContext) : ViewModel()`
### Konstruktor-Parameter
| Parameter | Typ | Beschreibung |
| :--- | :--- | :--- |
| `coroutineContext` | `CoroutineContext` | Der Kontext, in dem die Coroutines für Bildoperationen ausgeführt werden. |
---
## 2. Status-Management (State)
### `viewStateFlow: StateFlow<AlbumViewState>`
Ein observierbarer Datenstrom, der den aktuellen Zustand der Bildverwaltung liefert. Er basiert auf der Datenklasse `AlbumViewState`.
**Wichtige Felder im State:**
* **`tempFileUrl`**: Enthält die URL der temporären Datei, in der das mit der Kamera aufgenommene Bild zwischengespeichert wird.
* **`selectedPictures`**: Enthält die Liste der mit der Kamera aufgenommenen oder aus der Galerie ausgewählten Bilder (als `ImageBitmap`).
---
## 3. Zentrale Methoden
### `onReceive(intent: Intent)`
Diese Methode ist der zentrale Einstiegspunkt für alle Aktionen. Sie verarbeitet verschiedene `Intent`-Typen:
| Intent | Beschreibung |
| :--- | :--- |
| `OnPermissionGrantedWith` | Wird aufgerufen, wenn die Kamera-Berechtigung erteilt wurde. Erstellt eine temporäre Datei (`.jpg`) im Cache-Verzeichnis und generiert eine Inhalts-URI via `FileProvider`. |
| `OnPermissionDenied` | Loggt die Verweigerung der Kamera-Berechtigung durch den Nutzer. |
| `OnFinishPickingImagesWith` | Verarbeitet Bilder, die aus der Galerie ausgewählt wurden. Die URIs werden in `ImageBitmap` konvertiert und der Liste hinzugefügt. |
| `OnImageSavedWith` | Wird nach einer erfolgreichen Kameraaufnahme aufgerufen. Dekodiert das Bild aus der `tempFileUrl` und fügt es der Auswahl hinzu. |
| `OnImageSavingCanceled` | Setzt die `tempFileUrl` zurück, falls der Aufnahmevorgang abgebrochen wurde. |
### `clearSelection()`
Setzt die Liste der ausgewählten Bilder (`selectedPictures`) auf eine leere Liste zurück.
---
## 4. Funktionsweise & Datenfluss
### Bildverarbeitung (Galerie)
Beim Auswählen von Bildern aus der Galerie werden die `InputStreams` der bereitgestellten URIs ausgelesen. Um Speicher effizient zu nutzen, werden die Byte-Arrays mithilfe von `BitmapFactory` dekodiert und anschließend als Compose-kompatible `ImageBitmap` gespeichert.
### Bildverarbeitung (Kamera)
1. **Vorbereitung**: Ein `File.createTempFile` erstellt einen Platzhalter im Cache.
2. **Sicherheit**: Der `FileProvider` wandelt den Dateipfad in eine sichere URI um, damit die Kamera-App darauf zugreifen kann.
3. **Abschluss**: Nach der Aufnahme wird `ImageDecoder` genutzt, um die Datei in eine Bitmap umzuwandeln.
---
## 5. Technische Voraussetzungen
### FileProvider-Konfiguration
Damit die Kamera-App Bilder speichern kann, muss in der `AndroidManifest.xml` ein Provider definiert sein, der auf `${BuildConfig.APPLICATION_ID}.provider` hört.
### Abhängigkeiten
* **androidx.lifecycle:lifecycle-viewmodel-ktx**: Für den `viewModelScope`.
* **kotlinx-coroutines**: Für die asynchrone Verarbeitung der Bilddaten.
* **androidx.compose.ui:ui-graphics**: Für die Konvertierung in `ImageBitmap`.
---
## Fehlerbehandlung
* **NULL-Werte**: Falls ein InputStream nicht gelesen werden kann, wird eine Fehlermeldung geloggt, ohne die App zum Absturz zu bringen.
* **Speichermanagement**: Bilder werden als Bitmaps im Speicher gehalten. Bei sehr großen Mengen sollte eine Skalierung (Sampling) in der `BitmapFactory` implementiert werden.

45
docs/AlbumViewState.md Normal file
View File

@@ -0,0 +1,45 @@
## AlbumViewState
```kotlin
data class AlbumViewState(
val tempFileUrl: Uri? = null,
val selectedPictures: List<ImageBitmap> = emptyList()
)
```
**Zweck:** Immutable State-Container für Album-UI.
### Properties
| Name | Typ | Default | Beschreibung |
|------|-----|---------|--------------|
| `tempFileUrl` | `Uri?` | `null` | Temporäre URI für Kamera-Aufnahme |
| `selectedPictures` | `List<ImageBitmap>` | `emptyList()` | Alle ausgewählten/aufgenommenen Bilder |
---
## Verwendungsbeispiel
```kotlin
// Initialisierung
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
// State beobachten
val viewState by albumViewModel.viewStateFlow.collectAsState()
// Kamera öffnen
albumViewModel.onReceive(Intent.OnPermissionGrantedWith(context))
val cameraUri = viewState.tempFileUrl
// Bild gespeichert
albumViewModel.onReceive(Intent.OnImageSavedWith(context))
// Bilder hochladen
viewState.selectedPictures.forEach { bitmap ->
mapViewModel.uploadImageAsAttachment(bitmap)
}
albumViewModel.clearSelection()
```

0
docs/Color.md Normal file
View File

285
docs/DamageFilterSystem.md Normal file
View File

@@ -0,0 +1,285 @@
# DamageFilterSystem
**Zweck:** Filter-System für Feature Layer. Ermöglicht Filterung nach Schadenstyp, Bearbeitungsstatus und Datum (unabhängig kombinierbar).
**Komponenten:** UI-Dialog + Extension-Funktionen für MapViewModel.
---
## FilterCheckboxItem
```kotlin
@Composable
fun FilterCheckboxItem(
label: String,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
emoji: String = ""
)
```
**Zweck:** Wiederverwendbare Checkbox-Komponente mit Label und Emoji.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `label` | String | Angezeigter Text |
| `isChecked` | Boolean | Checkbox-Status |
| `onCheckedChange` | (Boolean) -> Unit | Callback bei Status-Änderung |
| `emoji` | String | Icon/Emoji rechts (default: "•") |
---
## DamageFilterDialog
```kotlin
@Composable
fun DamageFilterDialog(
damageTypes: List<String>,
currentFilters: Set<String>,
onDismiss: () -> Unit,
onApplyFilter: (Set<String>, Set<String>, LocalDate?, LocalDate?) -> Unit
)
```
**Zweck:** Vollbildschirm-Dialog zur Auswahl von Filter-Kriterien.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `damageTypes` | List<String> | Verfügbare Schadenstypen (z.B. "Straße", "Gehweg") |
| `currentFilters` | Set<String> | Aktuell aktive Typ-Filter |
| `onDismiss` | () -> Unit | Callback zum Schließen des Dialogs |
| `onApplyFilter` | (Set<String>, Set<String>, LocalDate?, LocalDate?) -> Unit | Callback mit (Typen, Status, StartDatum, EndDatum) |
### Filter-Typen
**1. Schadenstypen:**
- Straße 🛣️
- Gehweg 🚶
- Fahrradweg 🚴
- Beleuchtung 💡
- Sonstiges 📍
**2. Bearbeitungsstatus:**
- neu 🔴
- in Bearbeitung 🟠
- Schaden behoben 🟢
**3. Datums-Filter:**
- Von-Datum (dd.MM.yyyy)
- Bis-Datum (dd.MM.yyyy)
- Optional aktivierbar via Checkbox
### State Management
| State | Typ | Default | Beschreibung |
|-------|-----|---------|--------------|
| `selectedFilters` | Set<String> | alle Typen | Ausgewählte Schadenstypen |
| `selectedStatus` | Set<String> | alle Status | Ausgewählte Status |
| `startDateString` | String | "" | Eingabe Von-Datum |
| `endDateString` | String | "" | Eingabe Bis-Datum |
| `startDate` | LocalDate? | null | Geparste Von-Datum |
| `endDate` | LocalDate? | null | Geparste Bis-Datum |
| `useDateFilter` | Boolean | false | Datums-Filter aktiv |
### UI-Buttons
| Button | Funktion |
|--------|----------|
| "Alle" | Wählt alle Typen + alle Status |
| "Keine" | Deselektiert alle Typen + alle Status |
| "Filter anwenden" | Ruft `onApplyFilter()` auf und schließt Dialog |
### Datums-Parsing
**Format:** dd.MM.yyyy (z.B. 15.01.2024)
**Validierung:**
```kotlin
LocalDate.parse(input, DateTimeFormatter.ofPattern("dd.MM.yyyy"))
```
**Parsing:** Automatisch bei Eingabe (length == 10), fehlerhafte Eingaben werden ignoriert.
---
## applyDamageFilter (Extension Function)
```kotlin
suspend fun MapViewModel.applyDamageFilter(
selectedTypes: Set<String>,
selectedStatus: Set<String>,
startDate: LocalDate? = null,
endDate: LocalDate? = null
): Boolean
```
**Zweck:** Wendet Filter auf FeatureLayer via SQL WHERE-Clause an.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `selectedTypes` | Set<String> | Ausgewählte Schadenstypen |
| `selectedStatus` | Set<String> | Ausgewählte Status |
| `startDate` | LocalDate? | Start-Datum (optional) |
| `endDate` | LocalDate? | End-Datum (optional) |
**Return:** `Boolean` - true bei Erfolg, false bei Fehler
### Filter-Logik
**1. Typ-Filter:**
```sql
Typ IN ('Straße', 'Gehweg')
```
- Nur hinzugefügt wenn nicht alle Typen ausgewählt
- Verwendet SQL IN-Operator
**2. Status-Filter:**
```sql
status IN ('neu', 'in Bearbeitung')
```
- Nur hinzugefügt wenn nicht alle Status ausgewählt
- Verwendet SQL IN-Operator
**3. Datums-Filter:**
```sql
EditDate >= timestamp '2024-01-01 00:00:00'
AND EditDate <= timestamp '2024-12-31 23:59:59'
```
- Feldname: `EditDate`
- Format: SQL timestamp
- Unterstützt: Von, Bis, oder beides
**Kombination:**
```sql
Typ IN ('Straße') AND status IN ('neu') AND EditDate >= timestamp '...'
```
- Alle aktiven Filter werden mit AND verknüpft
### Snackbar-Feedback
**Format:** "Filter: {Typ-Info} | {Status-Info} | {Datum-Info}"
**Beispiele:**
- "Filter: 3 Typ(en) | 2 Status"
- "Filter: Datum: 01.01.2024 - 31.12.2024"
- "Alle Schäden werden angezeigt" (keine Filter)
### Threading
**Ausführung:** `withContext(Dispatchers.IO)` für Feature Query
**UI-Updates:** `withContext(Dispatchers.Main)` für snackBarMessage
---
## getActiveFilters (Extension Function)
```kotlin
fun MapViewModel.getActiveFilters(): Set<String>
```
**Zweck:** Extrahiert aktuell aktive Filter aus FeatureLayer.definitionExpression.
**Return:** Set<String> - Menge der gefilterten Werte (z.B. {"Straße", "Gehweg"})
**Extraktion:**
```kotlin
val regex = "'([^']+)'".toRegex()
regex.findAll(expression).map { it.groupValues[1] }.toSet()
```
**Beispiel:**
```
Expression: "Typ IN ('Straße', 'Gehweg')"
Return: {"Straße", "Gehweg"}
```
---
## Verwendungsbeispiel
```kotlin
// Dialog anzeigen
var showFilterDialog by remember { mutableStateOf(false) }
if (showFilterDialog) {
DamageFilterDialog(
damageTypes = MapViewModel.DAMAGE_TYPES,
currentFilters = mapViewModel.getActiveFilters(),
onDismiss = { showFilterDialog = false },
onApplyFilter = { types, status, start, end ->
coroutineScope.launch {
mapViewModel.applyDamageFilter(types, status, start, end)
}
}
)
}
// Aus MainScreen
SliderMenuItem(
text = "Schäden filtern",
icon = Icons.Default.FilterAlt,
onClick = { showFilterDialog = true }
)
```
---
## SQL-Beispiele
**Nur Typ:**
```sql
Typ IN ('Straße', 'Gehweg')
```
**Nur Status:**
```sql
status IN ('neu', 'in Bearbeitung')
```
**Nur Datum:**
```sql
EditDate >= timestamp '2024-01-01 00:00:00'
AND EditDate <= timestamp '2024-12-31 23:59:59'
```
**Alle kombiniert:**
```sql
Typ IN ('Straße')
AND status IN ('neu')
AND EditDate >= timestamp '2024-01-01 00:00:00'
```
**Keine Filter (alle anzeigen):**
```sql
(empty string)
```
---
## Feature-Attribute
**Erforderliche Felder im Feature Layer:**
| Feldname | Typ | Werte | Beschreibung |
|----------|-----|-------|--------------|
| `Typ` | String | "Straße", "Gehweg", etc. | Schadenstyp |
| `status` | String | "neu", "in Bearbeitung", "Schaden behoben" | Bearbeitungsstatus |
| `EditDate` | Timestamp | SQL timestamp | Letzte Änderung |
---
## Technische Details
- **UI Framework:** Jetpack Compose
- **Dialog:** Material3 Card im Dialog
- **State:** remember/mutableStateOf
- **Threading:** Coroutines (Dispatchers.IO/Main)
- **SQL:** ArcGIS SQL-Syntax (definitionExpression)
- **Datum:** Java Time API (LocalDate, DateTimeFormatter)

387
docs/DamageListSystem.md Normal file
View File

@@ -0,0 +1,387 @@
# DamageListDialog
**Zweck:** Dialog zur Anzeige von Straßenschäden in der Nähe mit Foto-Vorschau, Entfernungs- und Relevanz-Filter.
**Features:** GPS-basierte Entfernungsberechnung, Sortierung nach Distanz/Relevanz, dynamischer Radius-Filter.
---
## Data Classes
### DamageWithDistance
```kotlin
data class DamageWithDistance(
val feature: ArcGISFeature,
val distanceInMeters: Double?,
val typ: String,
val beschreibung: String,
val objectId: Long,
val rating: Int,
val photo: ImageBitmap? = null
)
```
**Zweck:** Container für Feature mit berechneter Distanz und geladenen Daten.
**Properties:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `feature` | ArcGISFeature | Originales ArcGIS Feature |
| `distanceInMeters` | Double? | Entfernung zum Nutzer in Metern |
| `typ` | String | Schadenstyp (z.B. "Straße") |
| `beschreibung` | String | Schadensbeschreibung |
| `objectId` | Long | Feature OBJECTID |
| `rating` | Int | Community-Bewertungen (communitycounter) |
| `photo` | ImageBitmap? | Erstes Attachment als Bitmap (optional) |
---
## Enums
### SortBy
```kotlin
enum class SortBy {
DISTANCE, // Nach Entfernung sortieren (nächste zuerst)
RELEVANCE // Nach Relevanz sortieren (höchste communitycounter zuerst)
}
```
**Zweck:** Sortierungs-Modi für Schadensliste.
---
## Functions
### calculateDistance
```kotlin
fun calculateDistance(
lat1: Double,
lon1: Double,
lat2: Double,
lon2: Double
): Double
```
**Zweck:** Berechnet Luftlinie zwischen zwei GPS-Koordinaten.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `lat1` | Double | Breitengrad Punkt 1 |
| `lon1` | Double | Längengrad Punkt 1 |
| `lat2` | Double | Breitengrad Punkt 2 |
| `lon2` | Double | Längengrad Punkt 2 |
**Return:** Entfernung in Metern
**Algorithmus:** Haversine-Formel
```kotlin
R = 6371000.0 // Erdradius in Metern
dLat = toRadians(lat2 - lat1)
dLon = toRadians(lon2 - lon1)
a = sin²(dLat/2) + cos(lat1) * cos(lat2) * sin²(dLon/2)
c = 2 * atan2(a, (1-a))
distance = R * c
```
---
## Composables
### DamageListDialog
```kotlin
@Composable
fun DamageListDialog(
onDismiss: () -> Unit,
onDamageClick: (ArcGISFeature) -> Unit,
mapViewModel: MapViewModel
)
```
**Zweck:** Haupt-Dialog mit Filter- und Sortieroptionen.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `onDismiss` | () -> Unit | Callback zum Schließen |
| `onDamageClick` | (ArcGISFeature) -> Unit | Callback bei Feature-Auswahl |
| `mapViewModel` | MapViewModel | ViewModel für Feature-Zugriff |
### State Management
| State | Typ | Default | Beschreibung |
|-------|-----|---------|--------------|
| `damages` | List<DamageWithDistance> | emptyList() | Geladene Schäden |
| `isLoading` | Boolean | true |Lade-Status |
| `maxDistance` | Float | 1000f | Radius-Filter in Metern |
| `userLocation` | Point? | null | GPS-Position |
| `errorMessage` | String? | null | Fehlermeldung |
| `sortBy` | SortBy | DISTANCE | Sortierungs-Modus |
| `minRelevance` | Int | 0 | Minimale Bewertungen |
### Filter-Optionen
**1. Entfernungs-Filter:**
- **Range:** 100m - 5000m (5km)
- **Steps:** 48
- **UI:** Slider mit Icon 📍
- **Anzeige:** "{Distanz} m" oder "{Distanz} km"
**2. Sortierung:**
- **DISTANCE:** Nach Entfernung (nächste zuerst)
- **RELEVANCE:** Nach communitycounter (höchste zuerst)
- **UI:** FilterChips "📍 Entfernung" / "👥 Relevanz"
**3. Relevanz-Filter (nur bei RELEVANCE):**
- **Range:** 0 - 50+
- **Steps:** 49
- **UI:** Slider mit Icon 📈
- **Anzeige:** "Min. Bewertungen: {count}+"
### UI-Zustände
**Loading:**
```
CircularProgressIndicator
"Lade Schäden..."
```
**Error:**
```
⚠️ Emoji
Fehlermeldung (rot)
```
**Empty:**
```
🔍 Emoji
"Keine Schäden im Umkreis"
oder "Keine Schäden mit dieser Bewertung"
```
**Success:**
```
LazyColumn mit DamageListItemWithPhoto
```
---
### DamageListItemWithPhoto
```kotlin
@Composable
fun DamageListItemWithPhoto(
damage: DamageWithDistance,
onClick: () -> Unit
)
```
**Zweck:** Einzelne Zeile in der Schadensliste.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `damage` | DamageWithDistance | Anzuzeigender Schaden |
| `onClick` | () -> Unit | Callback bei Klick |
**Layout:**
```
┌─────────────────────────────────────┐
│ ┌──────┐ {Emoji} {Typ} │
│ │ │ {Beschreibung} │
│ │ Foto │ 📍 {Distanz} 👥 {Rating} │
│ └──────┘ │
└─────────────────────────────────────┘
```
**Foto-Box:**
- **Größe:** 100x100 dp
- **Shape:** RoundedCornerShape (links abgerundet)
- **Fallback:** 📷 Emoji auf grauem Hintergrund
**Info-Bereich:**
- **Typ:** Emoji + Name (Bold)
- **Beschreibung:** Max. 40 Zeichen + "..."
- **Distanz:** 📍 + formatDistance()
- **Rating:** 👥 + communitycounter (farbcodiert)
---
## Extension Functions
### MapViewModel.loadDamagesNearby
```kotlin
suspend fun MapViewModel.loadDamagesNearby(
userLocation: Point,
maxDistanceMeters: Double,
context: Context
): List<DamageWithDistance>
```
**Zweck:** Lädt alle Features im Umkreis mit Distanz-Berechnung und Foto-Loading.
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `userLocation` | Point | GPS-Position des Nutzers |
| `maxDistanceMeters` | Double | Maximaler Radius |
| `context` | Context | Für Toast-Nachrichten |
**Return:** List<DamageWithDistance> - Sortiert nach Entfernung
**Ablauf:**
1. **Query:** Alle Features (`whereClause = "1=1"`)
2. **Für jedes Feature:**
- Load Feature-Daten
- Geometrie extrahieren
- Koordinaten-Transformation (GeometryEngine.projectOrNull)
- Distanz-Berechnung (Haversine)
- Filter: nur wenn ≤ maxDistanceMeters
- Attribute extrahieren (Typ, Beschreibung, OBJECTID, communitycounter)
- Erstes Attachment laden und zu Bitmap konvertieren
3. **Return:** Nach Distanz sortierte Liste
**Koordinaten-Transformation:**
```kotlin
GeometryEngine.projectOrNull(
featureGeometry,
userLocation.spatialReference
)
```
**Foto-Loading:**
```kotlin
val attachments = feature.fetchAttachments().getOrNull()
val firstAttachment = attachments?.firstOrNull()
val data = firstAttachment?.fetchData().getOrNull()
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
```
**Error Handling:**
- Try-Catch um gesamte Funktion
- Toast bei Fehler
- Return emptyList() bei Fehler
**Debug-Logging:**
- Query-Status
- Feature-Count
- Distanz-Berechnungen
- Transformation-Fehler
---
## Helper Functions
### formatDistance
```kotlin
fun formatDistance(meters: Double?): String
```
**Zweck:** Formatiert Distanz-Anzeige.
**Logic:**
- `< 1000m`: "{meters} m"
- `≥ 1000m`: "{km} km" (1 Dezimalstelle)
**Beispiele:**
- `250.0` → "250 m"
- `1500.0` → "1.5 km"
### getEmojiForType
```kotlin
fun getEmojiForType(typ: String): String
```
**Zweck:** Mapt Schadenstyp zu Emoji.
**Mapping:**
| Typ | Emoji |
|-----|-------|
| "Straße" | 🛣️ |
| "Gehweg" | 🚶 |
| "Fahrradweg" | 🚴 |
| "Beleuchtung" | 💡 |
| "Sonstiges" | 📍 |
---
## Verwendungsbeispiel
```kotlin
// In MainScreen
var showDamageList by remember { mutableStateOf(false) }
if (showDamageList) {
DamageListDialog(
onDismiss = { showDamageList = false },
onDamageClick = { feature ->
mapViewModel.selectedFeature = feature
mapViewModel.showFeatureInfo = true
showDamageList = false
},
mapViewModel = mapViewModel
)
}
// Aus SideSlider
SliderMenuItem(
text = "Schadensliste",
icon = Icons.Default.FormatListNumbered,
onClick = { showDamageList = true }
)
```
---
## Feature-Attribute
**Erforderliche Felder:**
| Feldname | Typ | Beschreibung |
|----------|-----|--------------|
| `Typ` | String | Schadenstyp |
| `Beschreibung` | String | Schadensbeschreibung |
| `OBJECTID` | Long | Feature-ID |
| `communitycounter` | Int | Community-Bewertungen |
| Attachments | Blob | Fotos (optional) |
---
## Performance-Hinweise
**Optimierungen:**
- Fotos werden parallel geladen (async per Feature)
- Distanz-Filter reduziert verarbeitete Features
- LazyColumn für effizientes Rendering
**Potenzielle Bottlenecks:**
- Foto-Loading bei vielen Features (async pro Feature)
- Koordinaten-Transformation (GeometryEngine)
- Haversine-Berechnung (für jedes Feature)
---
## Technische Details
- **UI Framework:** Jetpack Compose
- **Threading:** Dispatchers.IO für Feature-Loading
- **Geometrie:** ArcGIS GeometryEngine für Transformation
- **Distanz:** Haversine-Formel
- **Fotos:** BitmapFactory + ImageBitmap
- **State:** remember/mutableStateOf (lokaler State)

View File

@@ -0,0 +1,65 @@
# FeatureRatingSystem
## Übersicht
Das FeatureRatingSystem enthält Komponenten zur detaillierten Anzeige von gemeldeten Straßenschäden. Es ermöglicht Nutzern, Informationen und Fotos zu einem Schaden einzusehen und diesen über ein Community-Rating-System zu validieren.
---
## Methoden
## 1. FeatureInfoDialog
`Composable Function`
Ein modaler UI-Dialog, der zur Anzeige von Objektdaten eines `ArcGISFeature` dient.
### Zweck
Visualisierung von Sachdaten (Attribute), das asynchrone Laden von Bildanhängen und die Bereitstellung einer Schnittstelle für Nutzerinteraktionen (Bewertungen).
### Parameter
| Parameter | Typ | Beschreibung |
| :--- | :--- | :--- |
| `feature` | `ArcGISFeature?` | Das ArcGIS-Objekt, dessen Daten angezeigt werden. |
| `onDismiss` | `() -> Unit` | Callback zum Schließen des Dialogs. |
| `onRate` | `(ArcGISFeature, Boolean) -> Unit` | Callback, der ausgelöst wird, wenn ein Nutzer eine Bewertung abgibt. |
### Interne Zustandsvariablen (State)
* **`photoBitmaps`** (`List<ImageBitmap>`): Speichert die dekodierten Bilder, die aus den Feature-Attachments geladen wurden.
* **`isLoadingPhotos`** (`Boolean`): Statusindikator, der den Ladevorgang der Anhänge steuert.
### Funktionsweise
1. **Initialisierung**: Beim Start (`LaunchedEffect`) wird geprüft, ob das Feature vollständig geladen ist.
2. **Attachment-Download**: Die Funktion ruft `fetchAttachments()` auf. Für jeden Anhang werden die Rohdaten (`fetchData`) geladen.
3. **Bildverarbeitung**: Die Byte-Arrays werden mittels `BitmapFactory` dekodiert und in `ImageBitmap` konvertiert, um sie in Compose anzuzeigen.
4. **UI-Rendering**: Die Attribute `Typ` und `Beschreibung` werden zusammen mit den Bildern in einer scrollbaren `Card` dargestellt.
---
## 2. updateFeatureRating
`Extension Function (suspend)`
Eine Erweiterungsfunktion für das `MapViewModel`, die die Geschäftslogik für das Bewertungssystem kapselt.
### Zweck
Persistente Aktualisierung des Community-Zählers eines Schadens in der ArcGIS Online Feature Layer Table.
### Parameter
| Parameter | Typ | Beschreibung |
| :--- | :--- | :--- |
| `feature` | `ArcGISFeature` | Das zu bewertende Feature-Objekt. |
| `isPositive` | `Boolean` | `true` für eine Bestätigung (+1), `false` für eine Abmilderung (-1). |
| `context` | `Context` | Erforderlich für die Anzeige von UI-Feedback (Toasts). |
### Logik-Ablauf
1. **Wertberechnung**: Extrahiert das Attribut `communitycounter`. Erhöht oder verringert den Wert, wobei ein Minimum von `0` sichergestellt wird.
2. **Lokale Aktualisierung**: Setzt den neuen Wert im Attribut-Dictionary des Features und ruft `updateFeature()` auf der `ServiceFeatureTable` auf.
3. **Remote-Synchronisation**: Mittels `applyEdits()` werden die Änderungen an den ArcGIS-Server gesendet.
4. **Feedback**: Informiert den Nutzer via `Toast` und `SnackBar` über den Erfolg oder Fehler der Operation.
### Datenbank-Attribute (ArcGIS Schema)
* **`Typ`**: Identifikator für die Schadensart.
* **`Beschreibung`**: Optionaler Freitext des Erstellers.
* **`communitycounter`**: Ganzzahliger Wert zur Speicherung der Community-Validierungen.
---
## Fehlerbehandlung
* **Bild-Dekodierung**: Schlägt das Laden eines Bildes fehl, wird der Fehler geloggt, aber der Dialog bleibt funktionsfähig.
* **Netzwerk-Synchronisation**: Bei Fehlern während `applyEdits` wird eine Fehlermeldung ausgegeben, um den Nutzer über mangelnde Konnektivität zu informieren.

0
docs/MainActivity.md Normal file
View File

0
docs/MainScreen.md Normal file
View File

0
docs/MapSegment.md Normal file
View File

0
docs/MapViewModel.md Normal file
View File

View File

@@ -0,0 +1,72 @@
# ProximityNotificationService
## Übersicht
Der `ProximityNotificationService` im Paket `com.example.snapandsolve.service` ist ein **Android Foreground Service**. Er ermöglicht die Hintergrund-Überwachung des Nutzerstandorts, um proaktiv Benachrichtigungen auszulösen, wenn sich der Nutzer in der Nähe (Standard: 100m) eines in ArcGIS registrierten Straßenschadens befindet.
---
## 1. Architektur & Lebenszyklus
Der Dienst ist darauf ausgelegt, unabhängig von der Sichtbarkeit der App zu laufen. Als **Foreground Service** ist er mit einer permanenten System-Benachrichtigung verknüpft, um eine vorzeitige Beendigung durch das Android-Betriebssystem zu verhindern.
### Steuerung über das Companion Object
* **`start(context, featureTable)`**: Initialisiert den Dienst mit der notwendigen Datenquelle und startet ihn.
* **`stop(context)`**: Beendet das Tracking und den Dienst.
---
## 2. Funktionsweise des Standorts-Trackings
### Initialisierung
Nach dem Start (`onCreate`) wird die `SystemLocationDataSource` von ArcGIS initialisiert. Diese liefert kontinuierlich Standort-Updates innerhalb eines dedizierten `serviceScope` (CoroutineScope).
### Geofencing-Logik (`checkProximityToDamages`)
Bei jedem Standort-Update führt der Dienst eine räumliche Analyse durch:
1. **Abfrage**: Alle verfügbaren Features werden aus der `ServiceFeatureTable` abgerufen.
2. **Projektion**: Da GPS-Koordinaten (`SpatialReference.wgs84()`) oft nicht mit dem Koordinatensystem der Karte übereinstimmen, wird der Standort des Nutzers mittels `GeometryEngine.projectOrNull` transformiert.
3. **Distanzberechnung**: Die exakte Entfernung wird über `GeometryEngine.distanceGeodeticOrNull` berechnet. Hierbei wird der Kurventyp `Geodesic` verwendet, um die Erdkrümmung korrekt zu berücksichtigen.
---
## 3. Benachrichtigungs-Management
### Spam-Prävention
Um mehrfache Warnungen für denselben Schaden zu vermeiden, nutzt der Dienst ein internes Cache-System:
* **`notifiedFeatures`**: Ein Set von `OBJECTID`s, für die bereits eine Benachrichtigung gesendet wurde.
* **Hysterese-Bereinigung**: Eine ID wird erst dann aus dem Cache entfernt, wenn sich der Nutzer mehr als 150 Meter vom entsprechenden Schaden entfernt hat (`cleanupNotifiedFeatures`). Dies verhindert "flackernde" Benachrichtigungen an der Radiengrenze.
### Benachrichtigungs-Inhalt
Der Dienst sucht dynamisch nach verfügbaren Attributen im ArcGIS-Feature, um den Text zu generieren:
* **Kategorie**: Prüft Felder wie `Kategorie`, `kategorie`, `Category` oder `category`.
* **Beschreibung**: Sucht nach zusätzlichen Details in `Beschreibung` oder `Description`.
---
## 4. Konfiguration & Konstanten
| Konstante | Wert | Beschreibung |
| :--- | :--- | :--- |
| `PROXIMITY_RADIUS_METERS` | 100.0 | Radius, ab dem eine Warnung ausgelöst wird. |
| `CHANNEL_ID` | `proximity_notifications` | ID des Android-Benachrichtigungskanals. |
| `START_STICKY` | Konstante | Sorgt für einen automatischen Neustart des Dienstes nach einem System-Kill. |
---
## 5. Voraussetzungen & Sicherheit
### Erforderliche Berechtigungen
In der `AndroidManifest.xml` müssen folgende Berechtigungen konfiguriert sein:
* `ACCESS_FINE_LOCATION` (Präziser Standort)
* `ACCESS_BACKGROUND_LOCATION` (Standort im Hintergrund)
* `FOREGROUND_SERVICE` (Erlaubnis für Hintergrunddienste)
### Datenintegrität
Der Dienst setzt voraus, dass die `ServiceFeatureTable` beim Start übergeben wird. Ist die Tabelle `null`, stellt der Dienst die Überprüfung ein und loggt einen Fehler, bleibt aber als Foreground Service aktiv, um Systeminstabilitäten zu vermeiden.
---
## Fehlerbehandlung
* **Projektionsfehler**: Falls die Koordinatentransformation fehlschlägt, wird das betroffene Feature übersprungen.
* **Geometrie-Validierung**: Nur Features mit gültigen Punkt-Geometrien werden verarbeitet.

73
docs/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Snap And Solve - Setup & Konfiguration
## Voraussetzungen
### ArcGIS Feature Layer
Die App benötigt einen ArcGIS Feature Layer mit spezifischen Attributen. Es gibt zwei Möglichkeiten:
---
## Option 1: Fertiger Demo-Layer (empfohlen)
Verwende den vorkonfigurierten Feature Layer:
```
https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/251120_StrassenSchaeden/FeatureServer/0
```
Dieser Layer enthält bereits alle erforderlichen Attribute und ist sofort einsatzbereit.
---
## Option 2: Eigenen Layer konfigurieren
Falls du einen eigenen Feature Layer verwenden möchtest, müssen folgende Attribute hinzugefügt werden:
### Erforderliche Attribute
| Attributname | Typ | Beschreibung | Standardwert |
|--------------|-----|--------------|--------------|
| **communitycounter** | Integer | Anzahl der Nutzerbestätigungen (Upvotes/Downvotes) | 0 |
| **status** | String | Bearbeitungsstatus des Schadens | "neu" |
### Status-Werte
Das `status`-Attribut akzeptiert folgende Werte (siehe `StatusSymbolRenderer.kt`):
| Wert | Beschreibung | Farbe |
|------|--------------|-------|
| `"neu"` | Neu gemeldeter Schaden | 🔴 Rot |
| `"in Bearbeitung"` | Schaden wird bearbeitet | 🟠 Orange |
| `"Schaden behoben"` | Schaden wurde behoben | 🟢 Grün |
---
## Berechtigungen & Benutzerrollen
### Nutzer-Rechte (App-Benutzer)
**Erlaubt:**
- Neue Schäden melden
- Fotos hochladen
- Community-Bewertungen abgeben (Upvote/Downvote)
- Schäden filtern und anzeigen
**Nicht erlaubt:**
- Status eines Schadens ändern
- Features löschen
### Mitarbeiter-Rechte (Internes Team)
**Erlaubt:**
- Alle Nutzer-Rechte
- Status ändern (neu → in Bearbeitung → behoben)
- Features löschen
> **Hinweis:** Der Status wird bei Erstellung automatisch auf `"neu"` gesetzt. Die Statusänderung erfolgt durch interne Mitarbeiter außerhalb der App (z.B. über ArcGIS Online).
---

57
docs/SettingsScreen.md Normal file
View File

@@ -0,0 +1,57 @@
# Dokumentation: SettingsScreen (Einstellungen)
## Übersicht
Die Datei `SettingsScreen.kt` im Paket `com.example.snapandsolve.ui.theme` stellt die Benutzeroberfläche für die App-Konfiguration bereit. Sie dient primär der Steuerung des **ProximityNotificationService**, welcher Nutzer benachrichtigt, sobald sie sich in der Nähe eines gemeldeten Straßenschadens befinden.
---
## 1. Hauptkomponente: SettingsScreen
`@Composable fun SettingsScreen(onBack: () -> Unit, mapViewModel: MapViewModel)`
Ein Full-Screen Composable, das mittels Material Design 3 (M3) eine übersichtliche Struktur für Benutzereinstellungen bietet.
### Parameter
| Parameter | Typ | Beschreibung |
| :--- | :--- | :--- |
| `onBack` | `() -> Unit` | Callback zur Navigation zurück zum vorherigen Screen. |
| `mapViewModel` | `MapViewModel` | ViewModel zur Bereitstellung der ArcGIS Feature Table. |
### Zustandsverwaltung (State)
* **`isProximityActive`**: Ein via `collectAsState` beobachteter Boolean, der den aktuellen Status des Hintergrunddienstes direkt aus dem `ProximityNotificationService` widerspiegelt.
* **`notificationPermissionLauncher`**: Ein Activity-Result-Launcher, der die erforderliche Berechtigung `POST_NOTIFICATIONS` verwaltet.
---
## 2. Funktionslogik: Benachrichtigungs-Switch
Der zentrale Teil des Screens ist ein `Switch`, der den Proximity-Dienst steuert. Der Ablauf bei Aktivierung ist wie folgt:
1. **Versionsprüfung**: Prüft, ob die Berechtigung für Benachrichtigungen vorliegt.
2. **Berechtigungsanfrage**: Fehlt die Berechtigung, wird der System-Dialog zur Anfrage gestartet.
3. **Validierung der Datenquelle**: Es wird geprüft, ob die `FeatureTable` im `MapViewModel` verfügbar ist.
4. **Dienst-Start/Stop**:
- Bei Erfolg: `ProximityNotificationService.start(context, table)`
- Bei Deaktivierung: `ProximityNotificationService.stop(context)`
---
## 3. UI-Struktur & Design
### TopAppBar
Die Kopfzeile nutzt das Farbschema der App (`AppColor`) und bietet eine konsistente Navigation.
### Layout-Elemente
* **Settings-Karten (`Card`)**: Gruppieren inhaltlich zusammenhängende Einstellungen (z.B. Benachrichtigungen, Informationen).
* **Status-Feedback**: Wenn der Dienst aktiv ist, wird dynamisch eine zusätzliche Infokarte mit grünem Häkchen (`✓`) eingeblendet, um den aktiven Status zu visualisieren.
* **Informations-Sektion**: Zeigt feste Parameter wie den Proximity-Radius (aktuell 100 Meter) an.
---
## 4. Integration & Anforderungen
### Erforderliche Berechtigungen
In der `AndroidManifest.xml` müssen für die volle Funktionalität dieses Screens folgende Berechtigungen deklariert sein:
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

0
docs/SideSlider.md Normal file
View File

0
docs/Theme.md Normal file
View File

0
docs/Type.md Normal file
View File

View File

@@ -1,38 +0,0 @@
# Architektur
## Überblick
## Struktur
## Verantwortlichkeiten
## Prozessablauf
```mermaid
graph TD;
A-->B;
A-->C;
B-->D;
C-->D;
```
## Process Flow (Architecture)
```mermaid
flowchart LR
UI[UI: Screen / Fragment / Compose] -->|user action| VM[ViewModel]
VM -->|invoke| UC[Use Case]
UC -->|calls| R[Repository]
R -->|read/write| LDS[Local Data Source\nDB / DataStore]
R -->|fetch| RDS[Remote Data Source\nREST / GraphQL]
RDS -->|DTOs| MAP[Mapper]
LDS -->|Entities| MAP
MAP -->|Domain Model| UC
UC -->|Result| VM
VM -->|StateFlow / LiveData| UI
subgraph Data
R
LDS
RDS
MAP
end

83
docs/locationHelper.md Normal file
View File

@@ -0,0 +1,83 @@
# locationHelper
## Übersicht
Dieses Modul stellt Hilfsfunktionen und Composables bereit, um die GPS-Standortbestimmung innerhalb der ArcGIS Maps SDK für Kotlin (Compose-Toolkit) zu verwalten. Es kümmert sich um die Prüfung und Abfrage von Android-Laufzeitberechtigungen sowie die Initialisierung des `LocationDisplay`.
---
## 1. LocationHelper (Klasse)
`class LocationHelper(private val context: Context)`
Eine Utility-Klasse zur Kapselung von Berechtigungsprüfungen.
### Zweck
Zentralisierung der Logik für die Prüfung von Standortberechtigungen (`ACCESS_COARSE_LOCATION` und `ACCESS_FINE_LOCATION`).
### Methoden
| Methode | Rückgabetyp | Beschreibung |
| :--- | :--- | :--- |
| `hasLocationPermissions()` | `Boolean` | Gibt `true` zurück, wenn sowohl die grobe als auch die feine Standortberechtigung vom Nutzer erteilt wurde. |
---
## 2. setupLocationDisplay (Composable)
`@Composable fun setupLocationDisplay(autoPanMode: LocationDisplayAutoPanMode): LocationDisplay`
Die Haupt-Einstiegsfunktion für die Standortvisualisierung in einer MapView.
### Zweck
Initialisiert das `LocationDisplay`-Objekt, setzt den Modus für die automatische Schwenkung der Karte (Auto-Pan) und startet die Datenquelle für Standortaktualisierungen.
### Parameter
| Parameter | Typ | Default | Beschreibung |
| :--- | :--- | :--- | :--- |
| `autoPanMode` | `LocationDisplayAutoPanMode` | `.Recenter` | Bestimmt das Verhalten der Kamera bei Standortänderung (z.B. Zentrieren oder Navigieren). |
### Funktionsweise
1. **Initialisierung**: Erzeugt ein `LocationDisplay` mittels `rememberLocationDisplay()`.
2. **Berechtigungsprüfung**: Nutzt den `LocationHelper`, um den aktuellen Status zu prüfen.
3. **Datenquelle starten**:
- Sind Berechtigungen vorhanden: Startet die `dataSource` sofort via `LaunchedEffect`.
- Fehlen Berechtigungen: Ruft das Composable `RequestLocationPermissions` auf.
4. **Rückgabe**: Liefert das konfigurierte Objekt an die übergeordnete `MapView` zurück.
---
## 3. RequestLocationPermissions (Privates Composable)
`@Composable private fun RequestLocationPermissions(...)`
Ein UI-Komponente zur Interaktion mit dem Android-Berechtigungssystem.
### Zweck
Anforderung der erforderlichen Berechtigungen während der Laufzeit (Runtime Permissions).
### Parameter
| Parameter | Typ | Beschreibung |
| :--- | :--- | :--- |
| `context` | `Context` | Android-Kontext für Toast-Meldungen. |
| `onPermissionsGranted` | `() -> Unit` | Callback, der ausgeführt wird, wenn der Nutzer alle angeforderten Rechte bestätigt hat. |
### Ablauf
1. Nutzt `rememberLauncherForActivityResult`, um auf die Antwort des Betriebssystems zu warten.
2. Fordert im `LaunchedEffect` gleichzeitig `ACCESS_COARSE_LOCATION` und `ACCESS_FINE_LOCATION` an.
3. **Erfolg**: Ruft `onPermissionsGranted()` auf, was in der Regel den Start des GPS-Tracking auslöst.
4. **Ablehnung**: Zeigt eine `Toast`-Meldung an, um den Nutzer über die fehlende Funktionalität aufzuklären.
---
## Verwendete Berechtigungen (Manifest)
Für die korrekte Funktion müssen folgende Tags in der `AndroidManifest.xml` vorhanden sein:
* `android.permission.ACCESS_FINE_LOCATION`
* `android.permission.ACCESS_COARSE_LOCATION`
---
## Architektur-Hinweis
Das Modul nutzt das **ArcGIS Maps Compose Toolkit**. Das zurückgegebene `LocationDisplay` wird normalerweise direkt in einer `MapView` Composable als Parameter übergeben:
```kotlin
MapView(
modifier = Modifier.fillMaxSize(),
arcGISMap = map,
locationDisplay = setupLocationDisplay()
)

View File

@@ -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" }