Compare commits
17 Commits
f5ac96807c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5021becf5b | |||
| 7b5abed587 | |||
| 3b81269a57 | |||
| a2866cc268 | |||
| 8342723e09 | |||
| 48802606e8 | |||
| 7d6bf0fdd7 | |||
| 7781551e02 | |||
| 8eeeb2ce99 | |||
| d05da838a8 | |||
| fbf677c23a | |||
| 05426b687c | |||
| 33e95641d0 | |||
| c9b2b262a8 | |||
| 407316a4c5 | |||
| 30d5a17e6e | |||
| 97d86523ab |
6
.idea/appInsightsSettings.xml
generated
Normal file
6
.idea/appInsightsSettings.xml
generated
Normal 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>
|
||||||
12
.idea/deploymentTargetSelector.xml
generated
12
.idea/deploymentTargetSelector.xml
generated
@@ -4,14 +4,22 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-12-21T13:16:32.335138100Z">
|
<DropdownSelection timestamp="2026-02-06T10:29:08.485527Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</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>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal 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
8
.idea/markdown.xml
generated
Normal 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>
|
||||||
@@ -59,6 +59,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.compose.runtime)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SnapAndSolve">
|
android:theme="@style/Theme.SnapAndSolve">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.example.snapandsolve.MainActivity"
|
android:name="com.example.snapandsolve.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -26,10 +31,17 @@
|
|||||||
android:theme="@style/Theme.SnapAndSolve">
|
android:theme="@style/Theme.SnapAndSolve">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Service für Proximity-Benachrichtigungen -->
|
||||||
|
<service
|
||||||
|
android:name=".service.ProximityNotificationService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="location" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.example.snapandsolve
|
package com.example.snapandsolve
|
||||||
|
|
||||||
|
|
||||||
|
import MainScreen
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -20,19 +22,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SnapAndSolveTheme {
|
SnapAndSolveTheme {
|
||||||
MainScreen(application=application)
|
MainScreen(application=application, context=this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,75 @@
|
|||||||
package com.example.snapandsolve
|
import DamageFilterDialog
|
||||||
|
import DamageListDialog
|
||||||
import MapViewModel
|
import MapViewModel
|
||||||
import android.Manifest
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import android.content.Context
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.FilterAlt
|
import androidx.compose.material.icons.filled.FilterAlt
|
||||||
|
import androidx.compose.material.icons.filled.FormatListNumbered
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.ThumbUp
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
// Hier holen wir die ArcGIS Klassen
|
import applyDamageFilter
|
||||||
|
import com.arcgismaps.data.ArcGISFeature
|
||||||
import com.arcgismaps.mapping.ArcGISMap
|
import com.arcgismaps.mapping.ArcGISMap
|
||||||
import com.arcgismaps.mapping.BasemapStyle
|
import com.arcgismaps.mapping.BasemapStyle
|
||||||
import com.arcgismaps.mapping.Viewpoint
|
import com.arcgismaps.mapping.Viewpoint
|
||||||
import com.arcgismaps.toolkit.geoviewcompose.MapView
|
import com.arcgismaps.toolkit.geoviewcompose.MapView
|
||||||
// Hier deine eigenen Klassen (Pfade prüfen!)
|
import com.example.snapandsolve.FeatureInfoDialog
|
||||||
|
import com.example.snapandsolve.MapSegment
|
||||||
import com.example.snapandsolve.camera.AlbumViewModel
|
import com.example.snapandsolve.camera.AlbumViewModel
|
||||||
import com.example.snapandsolve.camera.AlbumViewState
|
|
||||||
import com.example.snapandsolve.camera.Intent
|
|
||||||
import com.example.snapandsolve.ui.theme.*
|
import com.example.snapandsolve.ui.theme.*
|
||||||
|
import com.example.snapandsolve.ui.theme.composable.SideSlider
|
||||||
|
import com.example.snapandsolve.ui.theme.composable.SliderMenuItem
|
||||||
|
import com.example.snapandsolve.updateFeatureRating
|
||||||
|
import com.example.snapandsolve.view.ReportDialog
|
||||||
|
import getActiveFilters
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
fun MainScreen(modifier: Modifier = Modifier, application: Application, context: Context) {
|
||||||
var showReport by rememberSaveable { mutableStateOf(false) }
|
var showReport by rememberSaveable { mutableStateOf(false) }
|
||||||
var sliderOpen by remember { mutableStateOf(false) }
|
var sliderOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// ViewModel Initialisierung
|
val mapViewModel = remember { MapViewModel(application, context) }
|
||||||
val mapViewModel = remember { MapViewModel(application) }
|
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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -56,26 +79,42 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
|||||||
modifier = Modifier.height(120.dp),
|
modifier = Modifier.height(120.dp),
|
||||||
containerColor = AppColor,
|
containerColor = AppColor,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false }) {
|
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false; showSettings = false }) {
|
||||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
LargeFloatingActionButton(
|
LargeFloatingActionButton(
|
||||||
onClick = { showReport = true; sliderOpen = false },
|
onClick = {
|
||||||
|
mapViewModel.resetDraft()
|
||||||
|
mapViewModel.closeFeatureInfo()
|
||||||
|
openReport()
|
||||||
|
},
|
||||||
modifier = Modifier.offset(y = 64.dp),
|
modifier = Modifier.offset(y = 64.dp),
|
||||||
containerColor = ButtonColor
|
containerColor = ButtonColor
|
||||||
) { Icon(Icons.Default.Add, contentDescription = "Add") }
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
floatingActionButtonPosition = FabPosition.Center,
|
floatingActionButtonPosition = FabPosition.Center,
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
ContentScreen(
|
if (showSettings) {
|
||||||
modifier = Modifier.padding(innerPadding),
|
SettingsScreen(
|
||||||
mapViewModel = mapViewModel,
|
onBack = ::closeSettings,
|
||||||
showReport = showReport,
|
mapViewModel = mapViewModel
|
||||||
sliderOpen = sliderOpen,
|
)
|
||||||
onDismissReport = { showReport = false })
|
} else {
|
||||||
|
ContentScreen(
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
mapViewModel = mapViewModel,
|
||||||
|
albumViewModel = albumViewModel,
|
||||||
|
showReport = showReport,
|
||||||
|
sliderOpen = sliderOpen,
|
||||||
|
onDismissReport = ::closeReport,
|
||||||
|
onOpenSettings = ::openSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,53 +122,96 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
|||||||
fun ContentScreen(
|
fun ContentScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
mapViewModel: MapViewModel,
|
mapViewModel: MapViewModel,
|
||||||
|
albumViewModel: AlbumViewModel,
|
||||||
showReport: Boolean,
|
showReport: Boolean,
|
||||||
sliderOpen: Boolean,
|
sliderOpen: Boolean,
|
||||||
onDismissReport: () -> Unit
|
onDismissReport: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
|
val context = LocalContext.current
|
||||||
val locationDisplay = setupLocationDisplay()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
// VERBINDUNG ZUM VIEWMODEL (Hier wird das GPS-Werkzeug übergeben)
|
var showFilterDialog by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(locationDisplay) {
|
var showDamageList by remember { mutableStateOf(false) }
|
||||||
mapViewModel.locationDisplay = locationDisplay
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
MapView(
|
// Map
|
||||||
|
MapSegment(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
arcGISMap = mapViewModel.map,
|
mapViewModel = mapViewModel
|
||||||
mapViewProxy = mapViewModel.mapViewProxy,
|
|
||||||
locationDisplay = locationDisplay
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (showReport) {
|
// Dialog
|
||||||
ReportOverlay(
|
if (showDamageList) {
|
||||||
onCancel = onDismissReport,
|
DamageListDialog(
|
||||||
onAdd = { beschreibung, typ ->
|
onDismiss = { showDamageList = false },
|
||||||
mapViewModel.createFeatureAtCurrentLocation(
|
onDamageClick = { feature ->
|
||||||
beschreibung = beschreibung,
|
mapViewModel.selectedFeature = feature
|
||||||
typ = typ,
|
mapViewModel.showFeatureInfo = true
|
||||||
photos = albumViewModel.viewStateFlow.value.selectedPictures
|
|
||||||
)
|
|
||||||
onDismissReport()
|
|
||||||
},
|
},
|
||||||
viewModel = albumViewModel
|
mapViewModel = mapViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SideSlider(visible = sliderOpen) {
|
// Report Overlay
|
||||||
SliderMenuItem(text = "Schäden filtern", icon = Icons.Default.FilterAlt, onClick = {})
|
if (showReport) {
|
||||||
|
ReportDialog(
|
||||||
|
onCancel = onDismissReport,
|
||||||
|
onClose = onDismissReport,
|
||||||
|
viewModel = albumViewModel,
|
||||||
|
mapViewModel = mapViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapViewModel.snackBarMessage.isNotEmpty()) {
|
// Feature Info Dialog
|
||||||
Snackbar(modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp)) {
|
if (mapViewModel.showFeatureInfo) {
|
||||||
Text(mapViewModel.snackBarMessage)
|
FeatureInfoDialog(
|
||||||
}
|
feature = mapViewModel.selectedFeature,
|
||||||
LaunchedEffect(mapViewModel.snackBarMessage) {
|
onDismiss = { mapViewModel.closeFeatureInfo() },
|
||||||
kotlinx.coroutines.delay(4000)
|
onRate = { feature, isPositive ->
|
||||||
mapViewModel.snackBarMessage = ""
|
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 = { showFilterDialog = true }
|
||||||
|
)
|
||||||
|
SliderMenuItem(
|
||||||
|
text = "Schadensliste",
|
||||||
|
icon = Icons.Default.FormatListNumbered,
|
||||||
|
onClick = { showDamageList = true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,210 +231,3 @@ fun AppTopBar(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ReportOverlay(
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
onAdd: (beschreibung: String, typ: String) -> Unit,
|
|
||||||
viewModel: AlbumViewModel
|
|
||||||
) {
|
|
||||||
val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState()
|
|
||||||
val currentContext = LocalContext.current
|
|
||||||
|
|
||||||
// State für Beschreibung und Typ
|
|
||||||
var beschreibung by remember { mutableStateOf("") }
|
|
||||||
var selectedTyp by remember { mutableStateOf("Schadenstyp wählen...") }
|
|
||||||
var dropdownExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// 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
|
|
||||||
fun startCamera() {
|
|
||||||
val hasPermission = currentContext.checkSelfPermission(Manifest.permission.CAMERA) ==
|
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
if (hasPermission) {
|
|
||||||
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
|
|
||||||
} else {
|
|
||||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kamera starten, wenn tempFileUrl gesetzt ist
|
|
||||||
LaunchedEffect(key1 = viewState.tempFileUrl) {
|
|
||||||
viewState.tempFileUrl?.let {
|
|
||||||
cameraLauncher.launch(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Typ-Auswahl (Dropdown)
|
|
||||||
Box {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { dropdownExpanded = true },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Typ: $selectedTyp")
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = dropdownExpanded,
|
|
||||||
onDismissRequest = { dropdownExpanded = false }
|
|
||||||
) {
|
|
||||||
listOf("Straße", "Gehweg", "Fahrradweg", "Beleuchtung","Sonstiges").forEach { typ ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(typ) },
|
|
||||||
onClick = {
|
|
||||||
selectedTyp = typ
|
|
||||||
dropdownExpanded = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kamera-Buttons
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Textfeld für Beschreibung
|
|
||||||
TextField(
|
|
||||||
value = beschreibung,
|
|
||||||
onValueChange = { beschreibung = it },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(220.dp),
|
|
||||||
placeholder = { Text("Beschreibung eingeben...") },
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = Color.White,
|
|
||||||
unfocusedContainerColor = Color.White
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bilder-Grid
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
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(beschreibung, selectedTyp) },
|
|
||||||
enabled = beschreibung.isNotBlank()
|
|
||||||
) {
|
|
||||||
Text("Hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createMap(): ArcGISMap {
|
|
||||||
return ArcGISMap(BasemapStyle.ArcGISTopographic).apply {
|
|
||||||
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
app/src/main/java/com/example/snapandsolve/MapSegment.kt
Normal file
122
app/src/main/java/com/example/snapandsolve/MapSegment.kt
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.arcgismaps.LoadStatus
|
import com.arcgismaps.LoadStatus
|
||||||
import com.arcgismaps.data.ArcGISFeature
|
import com.arcgismaps.data.ArcGISFeature
|
||||||
import com.arcgismaps.data.CodedValueDomain
|
import com.arcgismaps.data.CodedValueDomain
|
||||||
|
import com.arcgismaps.data.QueryParameters
|
||||||
import com.arcgismaps.data.ServiceFeatureTable
|
import com.arcgismaps.data.ServiceFeatureTable
|
||||||
import com.arcgismaps.geometry.GeometryEngine
|
import com.arcgismaps.geometry.GeometryEngine
|
||||||
import com.arcgismaps.geometry.Point
|
import com.arcgismaps.geometry.Point
|
||||||
@@ -17,29 +21,66 @@ import com.arcgismaps.mapping.ArcGISMap
|
|||||||
import com.arcgismaps.mapping.BasemapStyle
|
import com.arcgismaps.mapping.BasemapStyle
|
||||||
import com.arcgismaps.mapping.Viewpoint
|
import com.arcgismaps.mapping.Viewpoint
|
||||||
import com.arcgismaps.mapping.layers.FeatureLayer
|
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.LocationDisplay
|
||||||
|
import com.arcgismaps.mapping.view.ScreenCoordinate
|
||||||
|
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
|
||||||
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
|
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
|
||||||
|
import com.example.snapandsolve.view.createTypStatusRenderer
|
||||||
|
import com.example.snapandsolve.viewmodel.findNearbyDamageOfSameType
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.ByteArrayOutputStream
|
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 {
|
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
|
||||||
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
||||||
}
|
}
|
||||||
|
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)
|
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
|
||||||
val mapViewProxy = MapViewProxy()
|
val mapViewProxy = MapViewProxy()
|
||||||
|
var reportDraft by mutableStateOf(ReportDraft())
|
||||||
|
private set
|
||||||
lateinit var featureLayer: FeatureLayer
|
lateinit var featureLayer: FeatureLayer
|
||||||
var snackBarMessage: String by mutableStateOf("")
|
var snackBarMessage: String by mutableStateOf("")
|
||||||
lateinit var serviceFeatureTable: ServiceFeatureTable
|
lateinit var serviceFeatureTable: ServiceFeatureTable
|
||||||
var currentDamageType by mutableStateOf("")
|
var currentDamageType by mutableStateOf("")
|
||||||
var damageTypeList: List<String> = mutableListOf()
|
var damageTypeList: List<String> = mutableListOf()
|
||||||
var locationDisplay: LocationDisplay? = null
|
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 {
|
init {
|
||||||
|
tempOverlay.graphics.add(pointGraphic)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/arcgis/rest/services/si_StrassenSchaeden/FeatureServer/0")
|
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/251120_StrassenSchaeden/FeatureServer/0")
|
||||||
serviceFeatureTable.load().onSuccess {
|
serviceFeatureTable.load().onSuccess {
|
||||||
val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" }
|
val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" }
|
||||||
val attributeDomain = typeDamageField?.domain as? CodedValueDomain
|
val attributeDomain = typeDamageField?.domain as? CodedValueDomain
|
||||||
@@ -47,101 +88,104 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
damageTypeList += it.name
|
damageTypeList += it.name
|
||||||
}
|
}
|
||||||
println("DEBUG: ServiceFeatureTable erfolgreich geladen")
|
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 {
|
}.onFailure {
|
||||||
println("DEBUG: Fehler beim Laden der Tabelle: ${it.message}")
|
println("DEBUG: Fehler beim Laden der Tabelle: ${it.message}")
|
||||||
}
|
}
|
||||||
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
|
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
|
||||||
|
featureLayer.renderer = createTypStatusRenderer(context)
|
||||||
map.operationalLayers.add(featureLayer)
|
map.operationalLayers.add(featureLayer)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createFeatureAtCurrentLocation(
|
// ===== DEBUG: Felder nach dem Hinzufügen zur Map =====
|
||||||
beschreibung: String,
|
featureLayer.load().onSuccess {
|
||||||
typ: String,
|
println("DEBUG: FeatureLayer erfolgreich geladen")
|
||||||
photos: List<ImageBitmap> = emptyList()
|
val table = featureLayer.featureTable
|
||||||
) {
|
if (table != null) {
|
||||||
viewModelScope.launch {
|
println("DEBUG: Verfügbare Felder im FeatureLayer:")
|
||||||
println("DEBUG: createFeature gestartet") // Erscheint das in der Logcat?
|
table.fields.forEach { field ->
|
||||||
try {
|
println(" - ${field.name}")
|
||||||
// 1. Location prüfen
|
}
|
||||||
val locationData = locationDisplay?.location?.value
|
println("DEBUG: Ende Feldliste FeatureLayer")
|
||||||
val gpsPoint = locationData?.position
|
|
||||||
|
|
||||||
if (gpsPoint == null) {
|
|
||||||
println("DEBUG: Standort ist NULL - GPS Signal fehlt!")
|
|
||||||
snackBarMessage = "Kein GPS Signal. Bitte kurz warten oder nach draußen gehen."
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
println("DEBUG: GPS gefunden: x=${gpsPoint.x}, y=${gpsPoint.y}")
|
|
||||||
|
|
||||||
// 2. Feature erstellen
|
|
||||||
// Wichtig: explizit WGS84, falls es nicht schon hat
|
|
||||||
val pointWgs84 = Point(gpsPoint.x, gpsPoint.y, SpatialReference.wgs84())
|
|
||||||
|
|
||||||
val feature = serviceFeatureTable.createFeature().apply {
|
|
||||||
geometry = pointWgs84
|
|
||||||
attributes["Typ"] = typ
|
|
||||||
attributes["Beschreibung"] = beschreibung
|
|
||||||
}
|
|
||||||
|
|
||||||
println("DEBUG: Feature lokal erstellt. Sende an Server...")
|
|
||||||
|
|
||||||
// 3. Hochladen
|
|
||||||
serviceFeatureTable.addFeature(feature).onSuccess {
|
|
||||||
println("DEBUG: addFeature erfolgreich")
|
|
||||||
applyEditsWithPhotos(feature as ArcGISFeature, photos)
|
|
||||||
}.onFailure {
|
|
||||||
println("DEBUG: addFeature FEHLER: ${it.message}")
|
|
||||||
snackBarMessage = "Fehler: ${it.message}"
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("DEBUG: CRASH in createFeature: ${e.message}")
|
|
||||||
e.printStackTrace()
|
|
||||||
snackBarMessage = "Fehler: ${e.message}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>) {
|
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 ->
|
serviceFeatureTable.applyEdits().onSuccess { editResults ->
|
||||||
|
println("DEBUG applyEditsWithPhotos: applyEdits SUCCESS")
|
||||||
|
|
||||||
val result = editResults.firstOrNull()
|
val result = editResults.firstOrNull()
|
||||||
if (result != null && result.error == null) {
|
if (result != null && result.error == null) {
|
||||||
val serverObjectId = result.objectId
|
val serverObjectId = result.objectId
|
||||||
println("DEBUG: Server-Erfolg! Echte ObjectID: $serverObjectId")
|
println("DEBUG: Server-Erfolg! ObjectID: $serverObjectId")
|
||||||
|
|
||||||
if (photos.isNotEmpty()) {
|
if (photos.isNotEmpty()) {
|
||||||
// Fix: erstellen eine Abfrage für die neue ID
|
// Feature vom Server neu laden
|
||||||
val queryParameters = com.arcgismaps.data.QueryParameters().apply {
|
val queryParameters = QueryParameters().apply {
|
||||||
objectIds.add(serverObjectId)
|
objectIds.add(serverObjectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// laden das Feature neu vom Server
|
|
||||||
serviceFeatureTable.queryFeatures(queryParameters).onSuccess { queryResult ->
|
serviceFeatureTable.queryFeatures(queryParameters).onSuccess { queryResult ->
|
||||||
// Ein FeatureQueryResult ist ein Iterator, nehmen das erste Element
|
|
||||||
val fetchedFeature = queryResult.firstOrNull() as? ArcGISFeature
|
val fetchedFeature = queryResult.firstOrNull() as? ArcGISFeature
|
||||||
|
|
||||||
if (fetchedFeature != null) {
|
if (fetchedFeature != null) {
|
||||||
|
println("DEBUG: Feature neu geladen, füge Fotos hinzu...")
|
||||||
addPhotosToFeature(fetchedFeature, photos, serverObjectId)
|
addPhotosToFeature(fetchedFeature, photos, serverObjectId)
|
||||||
} else {
|
} else {
|
||||||
println("DEBUG: Feature nach Query nicht gefunden")
|
println("DEBUG: Feature nach Query nicht gefunden")
|
||||||
snackBarMessage = "Fehler: Feature-ID $serverObjectId nicht gefunden."
|
snackBarMessage = "Feature erstellt (ID: $serverObjectId), aber Fotos konnten nicht hinzugefügt werden."
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure { error ->
|
||||||
println("DEBUG: Query fehlgeschlagen: ${it.message}")
|
println("DEBUG: Query fehlgeschlagen: ${error.message}")
|
||||||
snackBarMessage = "Fotos konnten nicht zugeordnet werden."
|
snackBarMessage = "Feature erstellt, aber Fotos konnten nicht zugeordnet werden."
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
println("DEBUG: Keine Fotos, Feature erfolgreich erstellt!")
|
||||||
snackBarMessage = "Erfolgreich gemeldet! ID: $serverObjectId"
|
snackBarMessage = "Erfolgreich gemeldet! ID: $serverObjectId"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println("DEBUG: Server-Fehler bei applyEdits: ${result?.error?.message}")
|
println("DEBUG: Server-Fehler bei applyEdits: ${result?.error?.message}")
|
||||||
snackBarMessage = "Serverfehler: ${result?.error?.message}"
|
snackBarMessage = "Serverfehler: ${result?.error?.message}"
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure { error ->
|
||||||
println("DEBUG: applyEdits total fehlgeschlagen: ${it.message}")
|
println("DEBUG: applyEdits total fehlgeschlagen: ${error.message}")
|
||||||
snackBarMessage = "Senden fehlgeschlagen: ${it.message}"
|
error.printStackTrace()
|
||||||
|
snackBarMessage = "Senden fehlgeschlagen: ${error.message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +220,254 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
private fun imageBitmapToByteArray(imageBitmap: ImageBitmap): ByteArray {
|
private fun imageBitmapToByteArray(imageBitmap: ImageBitmap): ByteArray {
|
||||||
val stream = ByteArrayOutputStream()
|
val stream = ByteArrayOutputStream()
|
||||||
imageBitmap.asAndroidBitmap().compress(android.graphics.Bitmap.CompressFormat.JPEG, 80, stream)
|
imageBitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.JPEG, 80, stream)
|
||||||
return stream.toByteArray()
|
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()
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,39 @@
|
|||||||
package com.example.snapandsolve.camera
|
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
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 {
|
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()
|
data class OnPermissionGrantedWith(val compositionContext: Context): Intent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kamera-Permission wurde verweigert.
|
||||||
|
*/
|
||||||
data object OnPermissionDenied: Intent()
|
data object OnPermissionDenied: Intent()
|
||||||
|
|
||||||
data object OnImageSaved: Intent()
|
/**
|
||||||
|
* Kamera-Aufnahme wurde gespeichert.
|
||||||
data class OnImageSavedWith (val compositionContext: Context): Intent()
|
* @param compositionContext Android Context für Dateizugriff
|
||||||
|
*/
|
||||||
|
data class OnImageSavedWith(val compositionContext: Context): Intent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kamera-Aufnahme wurde abgebrochen.
|
||||||
|
*/
|
||||||
data object OnImageSavingCanceled: Intent()
|
data object OnImageSavingCanceled: Intent()
|
||||||
|
|
||||||
data class OnFinishPickingImages(val imageUrls: List<Uri>): Intent()
|
/**
|
||||||
|
* Bilder aus Galerie wurden ausgewählt.
|
||||||
data class OnFinishPickingImagesWith(val compositionContext: Context, val imageUrls: List<Uri>): Intent()
|
* @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()
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.example.snapandsolve.camera
|
package com.example.snapandsolve.camera
|
||||||
|
|
||||||
|
import Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
@@ -10,27 +10,35 @@ import androidx.core.content.FileProvider
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.snapandsolve.BuildConfig
|
import com.example.snapandsolve.BuildConfig
|
||||||
|
import com.example.snapandsolve.camera.AlbumViewState
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.CoroutineContext
|
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(
|
private val _albumViewState: MutableStateFlow<AlbumViewState> = MutableStateFlow(
|
||||||
AlbumViewState()
|
AlbumViewState()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable State für UI-Komponenten.
|
||||||
|
*/
|
||||||
val viewStateFlow: StateFlow<AlbumViewState>
|
val viewStateFlow: StateFlow<AlbumViewState>
|
||||||
get() = _albumViewState
|
get() = _albumViewState
|
||||||
//endregion
|
|
||||||
|
|
||||||
// region Intents
|
/**
|
||||||
|
* Verarbeitet Benutzer-Aktionen.
|
||||||
|
* @param intent Die zu verarbeitende Aktion
|
||||||
|
*/
|
||||||
fun onReceive(intent: Intent) = viewModelScope.launch(coroutineContext) {
|
fun onReceive(intent: Intent) = viewModelScope.launch(coroutineContext) {
|
||||||
when(intent) {
|
when (intent) {
|
||||||
is Intent.OnPermissionGrantedWith -> {
|
is Intent.OnPermissionGrantedWith -> {
|
||||||
println("DEBUG: OnPermissionGrantedWith empfangen")
|
println("DEBUG: OnPermissionGrantedWith empfangen")
|
||||||
val tempFile = File.createTempFile(
|
val tempFile = File.createTempFile(
|
||||||
@@ -40,7 +48,8 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
|
|||||||
)
|
)
|
||||||
println("DEBUG: TempFile erstellt: ${tempFile.absolutePath}")
|
println("DEBUG: TempFile erstellt: ${tempFile.absolutePath}")
|
||||||
|
|
||||||
val uri = FileProvider.getUriForFile(intent.compositionContext,
|
val uri = FileProvider.getUriForFile(
|
||||||
|
intent.compositionContext,
|
||||||
"${BuildConfig.APPLICATION_ID}.provider",
|
"${BuildConfig.APPLICATION_ID}.provider",
|
||||||
tempFile
|
tempFile
|
||||||
)
|
)
|
||||||
@@ -50,13 +59,11 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
is Intent.OnPermissionDenied -> {
|
is Intent.OnPermissionDenied -> {
|
||||||
// maybe log the permission denial event
|
|
||||||
println("User did not grant permission to use the camera")
|
println("User did not grant permission to use the camera")
|
||||||
}
|
}
|
||||||
|
|
||||||
is Intent.OnFinishPickingImagesWith -> {
|
is Intent.OnFinishPickingImagesWith -> {
|
||||||
if (intent.imageUrls.isNotEmpty()) {
|
if (intent.imageUrls.isNotEmpty()) {
|
||||||
// Handle picked images
|
|
||||||
val newImages = mutableListOf<ImageBitmap>()
|
val newImages = mutableListOf<ImageBitmap>()
|
||||||
for (eachImageUrl in intent.imageUrls) {
|
for (eachImageUrl in intent.imageUrls) {
|
||||||
val inputStream = intent.compositionContext.contentResolver.openInputStream(eachImageUrl)
|
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)
|
val bitmap: Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bitmapOptions)
|
||||||
newImages.add(bitmap.asImageBitmap())
|
newImages.add(bitmap.asImageBitmap())
|
||||||
} else {
|
} 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")
|
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
|
tempFileUrl = null
|
||||||
)
|
)
|
||||||
_albumViewState.value = newCopy
|
_albumViewState.value = newCopy
|
||||||
} else {
|
|
||||||
// user did not pick anything
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Intent.OnImageSavedWith -> {
|
is Intent.OnImageSavedWith -> {
|
||||||
val tempImageUrl = _albumViewState.value.tempFileUrl
|
val tempImageUrl = _albumViewState.value.tempFileUrl
|
||||||
if (tempImageUrl != null) {
|
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()
|
val currentPictures = _albumViewState.value.selectedPictures.toMutableList()
|
||||||
currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap())
|
currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap())
|
||||||
|
|
||||||
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null,
|
_albumViewState.value = _albumViewState.value.copy(
|
||||||
selectedPictures = currentPictures)
|
tempFileUrl = null,
|
||||||
|
selectedPictures = currentPictures
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Intent.OnImageSavingCanceled -> {
|
is Intent.OnImageSavingCanceled -> {
|
||||||
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null)
|
_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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
|
/**
|
||||||
|
* Löscht alle ausgewählten Bilder aus dem State.
|
||||||
|
*/
|
||||||
|
fun clearSelection() {
|
||||||
|
_albumViewState.value = _albumViewState.value.copy(selectedPictures = emptyList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,12 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||||||
*/
|
*/
|
||||||
data class AlbumViewState(
|
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,
|
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(),
|
val selectedPictures: List<ImageBitmap> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 -> "•"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -15,6 +15,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import com.example.snapandsolve.ui.theme.WidgetColor
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -42,9 +42,7 @@ class LocationHelper(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable zum Einrichten des Location Display
|
* Composable zum Einrichten des Location Display
|
||||||
*
|
* Beim Auslagern wird diese Funktion nicht mehr genutzt. Das sollte gefixt werden!!!!!!!!!!!!!!
|
||||||
* @param autoPanMode Wie die Karte dem Standort folgen soll (default: Recenter)
|
|
||||||
* @return LocationDisplay-Objekt, das an MapView übergeben werden kann
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun setupLocationDisplay(
|
fun setupLocationDisplay(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
app/src/main/java/com/example/snapandsolve/view/ReportDialog.kt
Normal file
262
app/src/main/java/com/example/snapandsolve/view/ReportDialog.kt
Normal 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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
BIN
app/src/main/res/drawable/bike.png
Normal file
BIN
app/src/main/res/drawable/bike.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z"/>
|
||||||
|
</vector>
|
||||||
BIN
app/src/main/res/drawable/lightbulb.png
Normal file
BIN
app/src/main/res/drawable/lightbulb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
app/src/main/res/drawable/motorway.png
Normal file
BIN
app/src/main/res/drawable/motorway.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/src/main/res/drawable/pedestrian.png
Normal file
BIN
app/src/main/res/drawable/pedestrian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/drawable/pin.png
Normal file
BIN
app/src/main/res/drawable/pin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
19
docs/AlbumEvents.md
Normal file
19
docs/AlbumEvents.md
Normal 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
77
docs/AlbumViewModel.md
Normal 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
45
docs/AlbumViewState.md
Normal 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
0
docs/Color.md
Normal file
285
docs/DamageFilterSystem.md
Normal file
285
docs/DamageFilterSystem.md
Normal 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
387
docs/DamageListSystem.md
Normal 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)
|
||||||
65
docs/FeatureRatingSystem.md
Normal file
65
docs/FeatureRatingSystem.md
Normal 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
0
docs/MainActivity.md
Normal file
0
docs/MainScreen.md
Normal file
0
docs/MainScreen.md
Normal file
0
docs/MapSegment.md
Normal file
0
docs/MapSegment.md
Normal file
0
docs/MapViewModel.md
Normal file
0
docs/MapViewModel.md
Normal file
72
docs/ProximityNotificationService.md
Normal file
72
docs/ProximityNotificationService.md
Normal 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
73
docs/README.md
Normal 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
57
docs/SettingsScreen.md
Normal 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
0
docs/SideSlider.md
Normal file
0
docs/Theme.md
Normal file
0
docs/Theme.md
Normal file
0
docs/Type.md
Normal file
0
docs/Type.md
Normal 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
83
docs/locationHelper.md
Normal 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()
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ activityCompose = "1.12.1"
|
|||||||
composeBom = "2024.09.00"
|
composeBom = "2024.09.00"
|
||||||
material3 = "1.4.0"
|
material3 = "1.4.0"
|
||||||
arcgisMapsKotlin = "200.8.0"
|
arcgisMapsKotlin = "200.8.0"
|
||||||
|
runtime = "1.10.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user