Compare commits
20 Commits
37d8bb064b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5021becf5b | |||
| 7b5abed587 | |||
| 3b81269a57 | |||
| a2866cc268 | |||
| 8342723e09 | |||
| 48802606e8 | |||
| 7d6bf0fdd7 | |||
| 7781551e02 | |||
| 8eeeb2ce99 | |||
| d05da838a8 | |||
| fbf677c23a | |||
| 05426b687c | |||
| 33e95641d0 | |||
| c9b2b262a8 | |||
| 407316a4c5 | |||
| 30d5a17e6e | |||
| 97d86523ab | |||
| f5ac96807c | |||
| 91b885f67c | |||
| 6b5ea3593b |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
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>
|
||||
14
.idea/deploymentTargetSelector.xml
generated
14
.idea/deploymentTargetSelector.xml
generated
@@ -4,14 +4,22 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-12-19T16:43:39.005516700Z">
|
||||
<DropdownSelection timestamp="2026-02-06T10:29:08.485527Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=N0AA003656K80600629" />
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
<DialogSelection>
|
||||
<targets>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\sklau\.android\avd\Medium_Phone.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</targets>
|
||||
</DialogSelection>
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
13
.idea/deviceManager.xml
generated
Normal file
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>
|
||||
@@ -58,6 +58,8 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
@@ -74,4 +76,8 @@ dependencies {
|
||||
implementation(platform(libs.arcgis.maps.kotlin.toolkit.bom))
|
||||
implementation(libs.arcgis.maps.kotlin.toolkit.geoview.compose)
|
||||
implementation(libs.arcgis.maps.kotlin.toolkit.authentication)
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -10,6 +10,10 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -19,6 +23,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SnapAndSolve">
|
||||
|
||||
<activity
|
||||
android:name="com.example.snapandsolve.MainActivity"
|
||||
android:exported="true"
|
||||
@@ -26,10 +31,17 @@
|
||||
android:theme="@style/Theme.SnapAndSolve">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Service für Proximity-Benachrichtigungen -->
|
||||
<service
|
||||
android:name=".service.ProximityNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.example.snapandsolve
|
||||
|
||||
|
||||
import MainScreen
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -20,19 +22,11 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel = AlbumViewModel(coroutineContext = Dispatchers.Default)
|
||||
|
||||
/*
|
||||
Wird gebraucht um die Karte in ArcGIS anzuzeigen. Die Prüfung ob man Zugang hat oder nicht
|
||||
wurde gelöscht.
|
||||
*/
|
||||
ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
SnapAndSolveTheme {
|
||||
MainScreen(application=application)
|
||||
MainScreen(application=application, context=this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,95 @@
|
||||
package com.example.snapandsolve
|
||||
|
||||
import android.Manifest
|
||||
import DamageFilterDialog
|
||||
import DamageListDialog
|
||||
import MapViewModel
|
||||
import android.app.Application
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Filter
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.FormatListNumbered
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardColors
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeFloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.ThumbUp
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import applyDamageFilter
|
||||
import com.arcgismaps.data.ArcGISFeature
|
||||
import com.arcgismaps.mapping.ArcGISMap
|
||||
import com.arcgismaps.mapping.BasemapStyle
|
||||
import com.arcgismaps.mapping.Viewpoint
|
||||
import com.arcgismaps.toolkit.geoviewcompose.MapView
|
||||
import com.example.snapandsolve.FeatureInfoDialog
|
||||
import com.example.snapandsolve.MapSegment
|
||||
import com.example.snapandsolve.camera.AlbumViewModel
|
||||
import com.example.snapandsolve.camera.AlbumViewState
|
||||
import com.example.snapandsolve.camera.Intent
|
||||
import com.example.snapandsolve.ui.theme.AppColor
|
||||
import com.example.snapandsolve.ui.theme.ButtonColor
|
||||
import com.example.snapandsolve.ui.theme.SideSlider
|
||||
import com.example.snapandsolve.ui.theme.SliderMenuItem
|
||||
import com.example.snapandsolve.ui.theme.WidgetColor
|
||||
import com.example.snapandsolve.ui.theme.setupLocationDisplay
|
||||
import com.example.snapandsolve.ui.theme.*
|
||||
import com.example.snapandsolve.ui.theme.composable.SideSlider
|
||||
import com.example.snapandsolve.ui.theme.composable.SliderMenuItem
|
||||
import com.example.snapandsolve.updateFeatureRating
|
||||
import com.example.snapandsolve.view.ReportDialog
|
||||
import getActiveFilters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@Composable
|
||||
fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
fun MainScreen(modifier: Modifier = Modifier, application: Application, context: Context) {
|
||||
var showReport by rememberSaveable { mutableStateOf(false) }
|
||||
var sliderOpen by remember { mutableStateOf(false) }
|
||||
var sliderOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val mapViewModel = remember { MapViewModel(application, context) }
|
||||
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
|
||||
|
||||
fun openReport() {
|
||||
showReport = true
|
||||
sliderOpen = false
|
||||
}
|
||||
|
||||
fun closeReport() {
|
||||
showReport = false
|
||||
}
|
||||
|
||||
fun openSettings() {
|
||||
showSettings = true
|
||||
sliderOpen = false
|
||||
}
|
||||
|
||||
fun closeSettings() {
|
||||
showSettings = false
|
||||
}
|
||||
|
||||
LaunchedEffect(mapViewModel.reopenReport) {
|
||||
if (mapViewModel.reopenReport) {
|
||||
showReport = true
|
||||
mapViewModel.consumeReopenReport()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
AppTopBar()
|
||||
},
|
||||
topBar = { AppTopBar() },
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
modifier = Modifier.height(120.dp),
|
||||
containerColor = AppColor,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
sliderOpen = !sliderOpen
|
||||
showReport = false
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Menu,
|
||||
contentDescription = "Menu",
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = { sliderOpen = !sliderOpen; showReport = false; showSettings = false }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
LargeFloatingActionButton(
|
||||
onClick = {
|
||||
showReport = true
|
||||
sliderOpen = false
|
||||
mapViewModel.resetDraft()
|
||||
mapViewModel.closeFeatureInfo()
|
||||
openReport()
|
||||
},
|
||||
modifier = Modifier.offset(y = 64.dp),
|
||||
containerColor = ButtonColor
|
||||
@@ -127,65 +99,118 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.Center,
|
||||
) { innerPadding ->
|
||||
ContentScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
application,
|
||||
showReport = showReport,
|
||||
sliderOpen = sliderOpen,
|
||||
onDismissReport = { showReport = false })
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
onBack = ::closeSettings,
|
||||
mapViewModel = mapViewModel
|
||||
)
|
||||
} else {
|
||||
ContentScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
mapViewModel = mapViewModel,
|
||||
albumViewModel = albumViewModel,
|
||||
showReport = showReport,
|
||||
sliderOpen = sliderOpen,
|
||||
onDismissReport = ::closeReport,
|
||||
onOpenSettings = ::openSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContentScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
application: Application,
|
||||
mapViewModel: MapViewModel,
|
||||
albumViewModel: AlbumViewModel,
|
||||
showReport: Boolean,
|
||||
sliderOpen: Boolean,
|
||||
onDismissReport: () -> Unit
|
||||
onDismissReport: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
val mapViewModel = remember { MapViewModel(application) }
|
||||
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// ArcGIS Map erstellen
|
||||
val map = remember {
|
||||
createMap() //Funktion zur Erstellung der Map
|
||||
}
|
||||
|
||||
//Standortbestimmung aus locationHelper.kt
|
||||
val locationDisplay = setupLocationDisplay()
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
var showDamageList by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// HINTERGRUND: Die Map
|
||||
MapView(
|
||||
// Map
|
||||
MapSegment(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
arcGISMap = map,
|
||||
locationDisplay = locationDisplay
|
||||
mapViewModel = mapViewModel
|
||||
)
|
||||
|
||||
// VORDERGRUND: Das Overlay (wenn showReport = true)
|
||||
if (showReport) {
|
||||
ReportOverlay(
|
||||
onCancel = onDismissReport,
|
||||
onAdd = { /* später */ },
|
||||
viewModel = albumViewModel
|
||||
// Dialog
|
||||
if (showDamageList) {
|
||||
DamageListDialog(
|
||||
onDismiss = { showDamageList = false },
|
||||
onDamageClick = { feature ->
|
||||
mapViewModel.selectedFeature = feature
|
||||
mapViewModel.showFeatureInfo = true
|
||||
},
|
||||
mapViewModel = mapViewModel
|
||||
)
|
||||
}
|
||||
|
||||
// RECHTSGRUND: Das Slider
|
||||
// Report Overlay
|
||||
if (showReport) {
|
||||
ReportDialog(
|
||||
onCancel = onDismissReport,
|
||||
onClose = onDismissReport,
|
||||
viewModel = albumViewModel,
|
||||
mapViewModel = mapViewModel
|
||||
)
|
||||
}
|
||||
|
||||
// Feature Info Dialog
|
||||
if (mapViewModel.showFeatureInfo) {
|
||||
FeatureInfoDialog(
|
||||
feature = mapViewModel.selectedFeature,
|
||||
onDismiss = { mapViewModel.closeFeatureInfo() },
|
||||
onRate = { feature, isPositive ->
|
||||
coroutineScope.launch {
|
||||
mapViewModel.updateFeatureRating(feature, isPositive, context)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Filter Dialog - AKTUALISIERT mit Status-Parameter
|
||||
if (showFilterDialog) {
|
||||
DamageFilterDialog(
|
||||
damageTypes = MapViewModel.DAMAGE_TYPES,
|
||||
currentFilters = mapViewModel.getActiveFilters(),
|
||||
onDismiss = { showFilterDialog = false },
|
||||
onApplyFilter = { selectedTypes, selectedStatus, startDate, endDate ->
|
||||
coroutineScope.launch {
|
||||
mapViewModel.applyDamageFilter(selectedTypes, selectedStatus, startDate, endDate)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Side Slider
|
||||
SideSlider(visible = sliderOpen) {
|
||||
Text(
|
||||
"Menü",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
SliderMenuItem(
|
||||
text = "Einstellungen",
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onOpenSettings
|
||||
)
|
||||
SliderMenuItem(
|
||||
text = "Schäden filtern",
|
||||
icon = Icons.Default.FilterAlt,
|
||||
onClick = {
|
||||
/* TODO */
|
||||
}
|
||||
onClick = { showFilterDialog = true }
|
||||
)
|
||||
SliderMenuItem(
|
||||
text = "Schadensliste",
|
||||
icon = Icons.Default.FormatListNumbered,
|
||||
onClick = { showDamageList = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -206,195 +231,3 @@ fun AppTopBar(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReportOverlay(
|
||||
onCancel: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
viewModel: AlbumViewModel
|
||||
) {
|
||||
val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState()
|
||||
val currentContext = LocalContext.current
|
||||
|
||||
// Launcher für Bildauswahl aus Galerie
|
||||
val pickImageFromAlbumLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.PickMultipleVisualMedia(20)
|
||||
) { urls ->
|
||||
viewModel.onReceive(Intent.OnFinishPickingImagesWith(currentContext, urls))
|
||||
}
|
||||
|
||||
// Launcher für Kamera
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.TakePicture()
|
||||
) { isImageSaved ->
|
||||
if (isImageSaved) {
|
||||
viewModel.onReceive(Intent.OnImageSavedWith(currentContext))
|
||||
} else {
|
||||
viewModel.onReceive(Intent.OnImageSavingCanceled)
|
||||
}
|
||||
}
|
||||
|
||||
// Launcher für Kamera-Berechtigung
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { permissionGranted ->
|
||||
if (permissionGranted) {
|
||||
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
|
||||
} else {
|
||||
viewModel.onReceive(Intent.OnPermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
// Funktion zum Starten der Kamera (prüft Berechtigung)
|
||||
fun startCamera() {
|
||||
println("DEBUG: startCamera() aufgerufen")
|
||||
val hasPermission = currentContext.checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
println("DEBUG: Hat Berechtigung? $hasPermission")
|
||||
if (hasPermission) {
|
||||
println("DEBUG: Erstelle tempFileUrl")
|
||||
// Berechtigung bereits erteilt -> direkt tempFileUrl erstellen
|
||||
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
|
||||
} else {
|
||||
println("DEBUG: Frage Berechtigung an")
|
||||
// Berechtigung anfragen
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
// Kamera starten, wenn tempFileUrl gesetzt ist
|
||||
LaunchedEffect(key1 = viewState.tempFileUrl) {
|
||||
viewState.tempFileUrl?.let {
|
||||
cameraLauncher.launch(it)
|
||||
}
|
||||
}
|
||||
|
||||
// leichter Dim-Hintergrund
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.25f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.heightIn(min = 400.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardColors(
|
||||
containerColor = WidgetColor,
|
||||
contentColor = ButtonColor,
|
||||
disabledContainerColor = Color.White,
|
||||
disabledContentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Schadensbeschreibung:",
|
||||
color = Color.Black
|
||||
)
|
||||
|
||||
// Kamera-Buttons (über der weißen Box)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
startCamera()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(text = "Foto aufnehmen")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
pickImageFromAlbumLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(text = "Aus Galerie")
|
||||
}
|
||||
}
|
||||
|
||||
// Die weiße Textfeld Box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.background(Color.White, RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
// Grid für ausgewählte Bilder (außerhalb der Box, aber in der Card)
|
||||
if (viewState.selectedPictures.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Ausgewählte Bilder (${viewState.selectedPictures.size})",
|
||||
color = Color.Black
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
userScrollEnabled = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(0.dp, 200.dp)
|
||||
) {
|
||||
itemsIndexed(viewState.selectedPictures) { index, picture ->
|
||||
Image(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
bitmap = picture,
|
||||
contentDescription = "Bild ${index + 1}",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
colors = ButtonColors(
|
||||
containerColor = ButtonColor,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = Color.White,
|
||||
disabledContentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Abbrechen",
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
Button(onClick = onAdd) { Text("Hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fun createMap(): ArcGISMap {
|
||||
|
||||
return ArcGISMap(BasemapStyle.ArcGISTopographic).apply {
|
||||
|
||||
initialViewpoint = Viewpoint(
|
||||
53.14,
|
||||
8.20,
|
||||
20000.0)
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
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,64 +1,473 @@
|
||||
package com.example.snapandsolve
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.arcgismaps.LoadStatus
|
||||
import com.arcgismaps.data.ArcGISFeature
|
||||
import com.arcgismaps.data.CodedValueDomain
|
||||
import com.arcgismaps.data.QueryParameters
|
||||
import com.arcgismaps.data.ServiceFeatureTable
|
||||
import com.arcgismaps.geometry.GeometryEngine
|
||||
import com.arcgismaps.geometry.Point
|
||||
import com.arcgismaps.geometry.SpatialReference
|
||||
import com.arcgismaps.mapping.ArcGISMap
|
||||
import com.arcgismaps.mapping.BasemapStyle
|
||||
import com.arcgismaps.mapping.Viewpoint
|
||||
import com.arcgismaps.mapping.layers.FeatureLayer
|
||||
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol
|
||||
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle
|
||||
import com.arcgismaps.mapping.view.Graphic
|
||||
import com.arcgismaps.mapping.view.GraphicsOverlay
|
||||
import com.arcgismaps.mapping.view.LocationDisplay
|
||||
import com.arcgismaps.mapping.view.ScreenCoordinate
|
||||
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
|
||||
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
|
||||
import com.example.snapandsolve.view.createTypStatusRenderer
|
||||
import com.example.snapandsolve.viewmodel.findNearbyDamageOfSameType
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class MapViewModel(application: Application ): AndroidViewModel(application) {
|
||||
|
||||
|
||||
class MapViewModel(application: Application, context: Context) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
// Zentrale Definition der Schadenstypen
|
||||
val DAMAGE_TYPES = listOf(
|
||||
"Straße",
|
||||
"Gehweg",
|
||||
"Fahrradweg",
|
||||
"Beleuchtung",
|
||||
"Sonstiges"
|
||||
)
|
||||
}
|
||||
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
|
||||
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
ALLES UNTER DIESEM KOMMENTAR WIRD NICHT GENUTZT. Aber EVENTUELL nötig zum einbinden von
|
||||
Layer und Features.
|
||||
*/
|
||||
// Hold a reference to the selected feature.
|
||||
var duplicateDamages by mutableStateOf<List<DamageWithDistance>>(emptyList())
|
||||
private set
|
||||
var selectedOperation by mutableStateOf(FeatureOperationType.DEFAULT)
|
||||
var reopenReport by mutableStateOf(false)
|
||||
private set
|
||||
var showFeatureInfo by mutableStateOf(false)
|
||||
public set
|
||||
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
|
||||
val mapViewProxy = MapViewProxy()
|
||||
|
||||
//var currentFeatureOperation by mutableStateOf(FeatureOperationType.CREATE)
|
||||
|
||||
var reportDraft by mutableStateOf(ReportDraft())
|
||||
private set
|
||||
lateinit var featureLayer: FeatureLayer
|
||||
|
||||
// Create a snackbar message to display the result of feature operations.
|
||||
var snackBarMessage: String by mutableStateOf("")
|
||||
|
||||
lateinit var serviceFeatureTable: ServiceFeatureTable
|
||||
|
||||
var currentDamageType by mutableStateOf("")
|
||||
|
||||
// The list of damage types to update the feature attribute.
|
||||
var damageTypeList: List<String> = mutableListOf()
|
||||
var locationDisplay: LocationDisplay? = null
|
||||
// Create a red circle simple marker symbol.
|
||||
val redCircleSymbol = SimpleMarkerSymbol(
|
||||
style = SimpleMarkerSymbolStyle.Circle,
|
||||
color = com.arcgismaps.Color.red,
|
||||
size = 10.0f
|
||||
)
|
||||
var pointGraphic = Graphic(null, redCircleSymbol)
|
||||
val tempOverlay = GraphicsOverlay()
|
||||
|
||||
init {
|
||||
tempOverlay.graphics.add(pointGraphic)
|
||||
viewModelScope.launch {
|
||||
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0")
|
||||
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/251120_StrassenSchaeden/FeatureServer/0")
|
||||
serviceFeatureTable.load().onSuccess {
|
||||
// Get the field from the feature table that will be updated.
|
||||
val typeDamageField = serviceFeatureTable.fields.first { it.name == "Typ" }
|
||||
// Get the coded value domain for the field.
|
||||
val attributeDomain = typeDamageField.domain as CodedValueDomain
|
||||
// Add the damage types to the list.
|
||||
attributeDomain.codedValues.forEach {
|
||||
val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" }
|
||||
val attributeDomain = typeDamageField?.domain as? CodedValueDomain
|
||||
attributeDomain?.codedValues?.forEach {
|
||||
damageTypeList += it.name
|
||||
}
|
||||
println("DEBUG: ServiceFeatureTable erfolgreich geladen")
|
||||
|
||||
// ===== DEBUG: Alle verfügbaren Felder ausgeben =====
|
||||
println("DEBUG: Verfügbare Felder in ServiceFeatureTable:")
|
||||
serviceFeatureTable.fields.forEach { field ->
|
||||
println(" - ${field.name}")
|
||||
}
|
||||
println("DEBUG: Ende Feldliste")
|
||||
|
||||
}.onFailure {
|
||||
println("DEBUG: Fehler beim Laden der Tabelle: ${it.message}")
|
||||
}
|
||||
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
|
||||
featureLayer.renderer = createTypStatusRenderer(context)
|
||||
map.operationalLayers.add(featureLayer)
|
||||
|
||||
// ===== DEBUG: Felder nach dem Hinzufügen zur Map =====
|
||||
featureLayer.load().onSuccess {
|
||||
println("DEBUG: FeatureLayer erfolgreich geladen")
|
||||
val table = featureLayer.featureTable
|
||||
if (table != null) {
|
||||
println("DEBUG: Verfügbare Felder im FeatureLayer:")
|
||||
table.fields.forEach { field ->
|
||||
println(" - ${field.name}")
|
||||
}
|
||||
println("DEBUG: Ende Feldliste FeatureLayer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pickCurrentLocation() {
|
||||
val pos = locationDisplay?.location?.value?.position
|
||||
if (pos == null) {
|
||||
snackBarMessage = "Kein GPS Signal. Bitte kurz warten oder Standort aktivieren."
|
||||
return
|
||||
}
|
||||
|
||||
// pos ist ggf. schon Point, aber wir erzwingen WGS84
|
||||
val pointWgs84 = if (pos.spatialReference == SpatialReference.wgs84()) {
|
||||
pos
|
||||
} else {
|
||||
GeometryEngine.projectOrNull(pos, SpatialReference.wgs84()) as Point
|
||||
}
|
||||
|
||||
// ===== KEIN Z hinzufügen! =====
|
||||
updateReportDraft { copy(point = pointWgs84) }
|
||||
pointGraphic.geometry = pointWgs84
|
||||
|
||||
println("DEBUG pickCurrentLocation: point = $pointWgs84")
|
||||
|
||||
snackBarMessage = "Position aus GPS gesetzt."
|
||||
}
|
||||
|
||||
private suspend fun applyEditsWithPhotos(feature: ArcGISFeature, photos: List<ImageBitmap>) {
|
||||
println("DEBUG applyEditsWithPhotos: START")
|
||||
println("DEBUG applyEditsWithPhotos: photos.size = ${photos.size}")
|
||||
|
||||
// ERST: Feature zum Server senden
|
||||
serviceFeatureTable.applyEdits().onSuccess { editResults ->
|
||||
println("DEBUG applyEditsWithPhotos: applyEdits SUCCESS")
|
||||
|
||||
val result = editResults.firstOrNull()
|
||||
if (result != null && result.error == null) {
|
||||
val serverObjectId = result.objectId
|
||||
println("DEBUG: Server-Erfolg! ObjectID: $serverObjectId")
|
||||
|
||||
if (photos.isNotEmpty()) {
|
||||
// Feature vom Server neu laden
|
||||
val queryParameters = QueryParameters().apply {
|
||||
objectIds.add(serverObjectId)
|
||||
}
|
||||
|
||||
serviceFeatureTable.queryFeatures(queryParameters).onSuccess { queryResult ->
|
||||
val fetchedFeature = queryResult.firstOrNull() as? ArcGISFeature
|
||||
|
||||
if (fetchedFeature != null) {
|
||||
println("DEBUG: Feature neu geladen, füge Fotos hinzu...")
|
||||
addPhotosToFeature(fetchedFeature, photos, serverObjectId)
|
||||
} else {
|
||||
println("DEBUG: Feature nach Query nicht gefunden")
|
||||
snackBarMessage = "Feature erstellt (ID: $serverObjectId), aber Fotos konnten nicht hinzugefügt werden."
|
||||
}
|
||||
}.onFailure { error ->
|
||||
println("DEBUG: Query fehlgeschlagen: ${error.message}")
|
||||
snackBarMessage = "Feature erstellt, aber Fotos konnten nicht zugeordnet werden."
|
||||
}
|
||||
} else {
|
||||
println("DEBUG: Keine Fotos, Feature erfolgreich erstellt!")
|
||||
snackBarMessage = "Erfolgreich gemeldet! ID: $serverObjectId"
|
||||
}
|
||||
} else {
|
||||
println("DEBUG: Server-Fehler bei applyEdits: ${result?.error?.message}")
|
||||
snackBarMessage = "Serverfehler: ${result?.error?.message}"
|
||||
}
|
||||
}.onFailure { error ->
|
||||
println("DEBUG: applyEdits total fehlgeschlagen: ${error.message}")
|
||||
error.printStackTrace()
|
||||
snackBarMessage = "Senden fehlgeschlagen: ${error.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addPhotosToFeature(
|
||||
feature: ArcGISFeature,
|
||||
photos: List<ImageBitmap>,
|
||||
objectId: Long?
|
||||
) {
|
||||
println("DEBUG: Füge ${photos.size} Fotos hinzu...")
|
||||
photos.forEachIndexed { index, imageBitmap ->
|
||||
try {
|
||||
val byteArray = imageBitmapToByteArray(imageBitmap)
|
||||
feature.addAttachment(
|
||||
name = "photo_$index.jpg",
|
||||
contentType = "image/jpeg",
|
||||
data = byteArray
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
println("DEBUG: Attachment-Fehler bei Foto $index: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
serviceFeatureTable.updateFeature(feature).onSuccess {
|
||||
println("DEBUG: Feature mit Anhängen aktualisiert. Finales applyEdits...")
|
||||
serviceFeatureTable.applyEdits().onSuccess {
|
||||
snackBarMessage = "Gespeichert mit ${photos.size} Fotos! ID: $objectId"
|
||||
}
|
||||
}.onFailure {
|
||||
println("DEBUG: updateFeature für Anhänge fehlgeschlagen: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun imageBitmapToByteArray(imageBitmap: ImageBitmap): ByteArray {
|
||||
val stream = ByteArrayOutputStream()
|
||||
imageBitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.JPEG, 80, stream)
|
||||
return stream.toByteArray()
|
||||
}
|
||||
|
||||
fun onTap(singleTapConfirmedEvent: SingleTapConfirmedEvent) {
|
||||
if (featureLayer.loadStatus.value != LoadStatus.Loaded) {
|
||||
snackBarMessage = "Layer not loaded!"
|
||||
return
|
||||
}
|
||||
|
||||
when (selectedOperation) {
|
||||
FeatureOperationType.DEFAULT -> selectFeatureAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
FeatureOperationType.UPDATE_ATTRIBUTE -> selectFeatureForAttributeEditAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
FeatureOperationType.UPDATE_GEOMETRY -> updateFeatureGeometryAt(singleTapConfirmedEvent.screenCoordinate)
|
||||
FeatureOperationType.PICK_REPORT_LOCATION -> pickReportLocation(singleTapConfirmedEvent.screenCoordinate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectFeatureForAttributeEditAt(screenCoordinate: ScreenCoordinate) {
|
||||
featureLayer?.let { featureLayer ->
|
||||
// Clear any existing selection.
|
||||
featureLayer.clearSelection()
|
||||
selectedFeature = null
|
||||
viewModelScope.launch {
|
||||
// Determine if a user tapped on a feature.
|
||||
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
|
||||
// Get the identified feature.
|
||||
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
|
||||
identifiedFeature?.let {
|
||||
val currentAttributeValue = it.attributes["typ"] as String
|
||||
currentDamageType = currentAttributeValue
|
||||
selectedFeature = it.also {
|
||||
featureLayer.selectFeature(it)
|
||||
}
|
||||
} ?: run {
|
||||
// Reset damage type if no feature identified.
|
||||
currentDamageType = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFeatureGeometryAt(screenCoordinate: ScreenCoordinate) {
|
||||
featureLayer?.let { featureLayer ->
|
||||
when (selectedFeature) {
|
||||
// When no feature is selected.
|
||||
null -> {
|
||||
viewModelScope.launch {
|
||||
// Determine if a user tapped on a feature.
|
||||
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess { identifyResult ->
|
||||
// Get the identified feature.
|
||||
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
|
||||
identifiedFeature?.let {
|
||||
selectedFeature = it.also {
|
||||
featureLayer.selectFeature(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
mapViewProxy.screenToLocationOrNull(screenCoordinate)?.let { mapPoint ->
|
||||
// Normalize the point - needed when the tapped location is over the international date line.
|
||||
val destinationPoint = GeometryEngine.normalizeCentralMeridian(mapPoint)
|
||||
viewModelScope.launch {
|
||||
selectedFeature?.let { selectedFeature ->
|
||||
// Load the feature.
|
||||
selectedFeature.load().onSuccess {
|
||||
// Update the geometry of the selected feature.
|
||||
selectedFeature.geometry = destinationPoint
|
||||
// Apply the edit to the feature table.
|
||||
serviceFeatureTable?.updateFeature(selectedFeature)
|
||||
// Push the update to the service with the service geodatabase.
|
||||
serviceFeatureTable?.applyEdits()?.onSuccess {
|
||||
snackBarMessage = "Moved feature ${selectedFeature.attributes["objectid"]}"
|
||||
}?.onFailure {
|
||||
snackBarMessage =
|
||||
"Failed to move feature ${selectedFeature.attributes["objectid"]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickReportLocation(screen: ScreenCoordinate) {
|
||||
val mapPoint = mapViewProxy.screenToLocationOrNull(screen)
|
||||
val normalized = mapPoint?.let { GeometryEngine.normalizeCentralMeridian(it) as? Point }
|
||||
|
||||
if (normalized != null) {
|
||||
// ===== KEIN Z hinzufügen! =====
|
||||
reportDraft = reportDraft.copy(point = normalized)
|
||||
pointGraphic.geometry = normalized
|
||||
reopenReport = true
|
||||
|
||||
println("DEBUG pickReportLocation: point = $normalized")
|
||||
|
||||
snackBarMessage = "Position gesetzt."
|
||||
} else {
|
||||
snackBarMessage = "Position konnte nicht gesetzt werden."
|
||||
}
|
||||
|
||||
selectedOperation = FeatureOperationType.DEFAULT
|
||||
}
|
||||
|
||||
|
||||
fun startPickReportLocation() {
|
||||
selectedOperation = FeatureOperationType.PICK_REPORT_LOCATION
|
||||
snackBarMessage = "Tippe auf die Karte, um die Position zu setzen."
|
||||
}
|
||||
|
||||
fun consumeReopenReport() {
|
||||
reopenReport = false
|
||||
}
|
||||
|
||||
fun resetDraft() {
|
||||
reportDraft = ReportDraft()
|
||||
pointGraphic.geometry = null
|
||||
selectedOperation = FeatureOperationType.DEFAULT
|
||||
}
|
||||
|
||||
fun updateReportDraft(update: ReportDraft.() -> ReportDraft) {
|
||||
reportDraft = reportDraft.update()
|
||||
}
|
||||
|
||||
fun submitDraftToLayer() {
|
||||
val draft = reportDraft
|
||||
if (!draft.isValid) {
|
||||
snackBarMessage = "Bitte Beschreibung, Typ, Position und Fotos setzen."
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
println("DEBUG: Creating feature...")
|
||||
|
||||
// ===== FIX: Point OHNE Z erstellen =====
|
||||
val point2D = if (draft.point != null) {
|
||||
Point(
|
||||
x = draft.point.x,
|
||||
y = draft.point.y,
|
||||
spatialReference = draft.point.spatialReference
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
println("DEBUG: Point 2D created: $point2D (hasZ = ${point2D?.hasZ})")
|
||||
|
||||
// 1) Feature lokal erstellen MIT 2D-Point
|
||||
val feature = serviceFeatureTable.createFeature().apply {
|
||||
geometry = point2D // ← OHNE Z!
|
||||
attributes["Beschreibung"] = draft.beschreibung
|
||||
attributes["Typ"] = draft.typ
|
||||
attributes["status"] = draft.status
|
||||
}
|
||||
|
||||
println("DEBUG: Adding feature to table...")
|
||||
|
||||
// 2) Feature zur Tabelle hinzufügen
|
||||
serviceFeatureTable.addFeature(feature).onSuccess {
|
||||
println("DEBUG: addFeature SUCCESS, calling applyEditsWithPhotos...")
|
||||
|
||||
// 3) JETZT ERST applyEdits mit Fotos
|
||||
applyEditsWithPhotos(feature as ArcGISFeature, draft.photos)
|
||||
|
||||
// 4) Draft zurücksetzen
|
||||
resetDraft()
|
||||
}.onFailure { error ->
|
||||
println("DEBUG: addFeature FAILED: ${error.message}")
|
||||
snackBarMessage = "Fehler beim Hinzufügen: ${error.message}"
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("DEBUG: Exception in submitDraftToLayer: ${e.message}")
|
||||
e.printStackTrace()
|
||||
snackBarMessage = "Fehler: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectFeatureAt(screenCoordinate: ScreenCoordinate) {
|
||||
featureLayer?.let { featureLayer ->
|
||||
// Clear any existing selection.
|
||||
featureLayer.clearSelection()
|
||||
selectedFeature = null
|
||||
viewModelScope.launch {
|
||||
// Determine if a user tapped on a feature.
|
||||
mapViewProxy.identify(featureLayer, screenCoordinate, 10.dp).onSuccess {
|
||||
identifyResult ->
|
||||
// Get the identified feature.
|
||||
val identifiedFeature = identifyResult.geoElements.firstOrNull() as? ArcGISFeature
|
||||
identifiedFeature?.let {
|
||||
selectedFeature = it.also {
|
||||
featureLayer.selectFeature(it)
|
||||
showFeatureInfo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeFeatureInfo() {
|
||||
showFeatureInfo = false
|
||||
selectedFeature = null
|
||||
featureLayer.clearSelection()
|
||||
}
|
||||
|
||||
suspend fun isDuplicateNearby(radiusMeters: Double): Boolean {
|
||||
val p = reportDraft.point ?: return false
|
||||
val t = reportDraft.typ
|
||||
duplicateDamages = findNearbyDamageOfSameType(serviceFeatureTable, p, t, radiusMeters)
|
||||
return duplicateDamages.isNotEmpty()
|
||||
}
|
||||
|
||||
fun clearDuplicateDamages() {
|
||||
duplicateDamages = emptyList()
|
||||
}
|
||||
|
||||
fun getFeatureTable(): ServiceFeatureTable? {
|
||||
return featureLayer?.featureTable as? ServiceFeatureTable
|
||||
}
|
||||
}
|
||||
|
||||
enum class FeatureOperationType(val operationName: String, val instruction: String) {
|
||||
DEFAULT("Default", ""),
|
||||
UPDATE_ATTRIBUTE("Update attribute", "Select an existing feature to edit its attribute."),
|
||||
UPDATE_GEOMETRY("Update geometry", "Select an existing feature and tap the map to move it to a new position."),
|
||||
PICK_REPORT_LOCATION("Pick report location", "Tippe auf die Karte, um die Position zu setzen."),
|
||||
}
|
||||
|
||||
data class ReportDraft(
|
||||
val beschreibung: String = "",
|
||||
val typ: String = "Schadenstyp wählen...",
|
||||
val photos: List<ImageBitmap> = emptyList(),
|
||||
val point: Point? = null,
|
||||
val status: String = "neu"
|
||||
) {
|
||||
val isValid: Boolean
|
||||
get() =
|
||||
beschreibung.isNotBlank() &&
|
||||
typ != "Schadenstyp wählen..." &&
|
||||
point != null &&
|
||||
photos.isNotEmpty()
|
||||
}
|
||||
@@ -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.net.Uri
|
||||
/*
|
||||
Das sind die Funktionen beim Drücken der Knöpfe. Übersichtlicher wäre es sie direkt mit den
|
||||
Knöpfen in der ViewModel zu platzieren. AlbumEvents als name ist vielleicht unglücklich gewählt.
|
||||
*/
|
||||
|
||||
/**
|
||||
* User generated events that can be triggered from the UI.
|
||||
* Benutzer-Aktionen für das Album/Kamera-System.
|
||||
*/
|
||||
sealed class Intent {
|
||||
data object OnPermissionGranted: Intent()
|
||||
|
||||
/**
|
||||
* Kamera-Permission wurde erteilt.
|
||||
* @param compositionContext Android Context für Dateizugriff
|
||||
*/
|
||||
data class OnPermissionGrantedWith(val compositionContext: Context): Intent()
|
||||
|
||||
/**
|
||||
* Kamera-Permission wurde verweigert.
|
||||
*/
|
||||
data object OnPermissionDenied: Intent()
|
||||
|
||||
data object OnImageSaved: Intent()
|
||||
|
||||
data class OnImageSavedWith (val compositionContext: Context): Intent()
|
||||
/**
|
||||
* Kamera-Aufnahme wurde gespeichert.
|
||||
* @param compositionContext Android Context für Dateizugriff
|
||||
*/
|
||||
data class OnImageSavedWith(val compositionContext: Context): Intent()
|
||||
|
||||
/**
|
||||
* Kamera-Aufnahme wurde abgebrochen.
|
||||
*/
|
||||
data object OnImageSavingCanceled: Intent()
|
||||
|
||||
data class OnFinishPickingImages(val imageUrls: List<Uri>): Intent()
|
||||
|
||||
data class OnFinishPickingImagesWith(val compositionContext: Context, val imageUrls: List<Uri>): Intent()
|
||||
}
|
||||
/**
|
||||
* Bilder aus Galerie wurden ausgewählt.
|
||||
* @param compositionContext Android Context für Dateizugriff
|
||||
* @param imageUrls Liste der ausgewählten Bild-URIs
|
||||
*/
|
||||
data class OnFinishPickingImagesWith(
|
||||
val compositionContext: Context,
|
||||
val imageUrls: List<Uri>
|
||||
): Intent()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.example.snapandsolve.camera
|
||||
|
||||
|
||||
import Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
@@ -10,27 +10,35 @@ import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.snapandsolve.BuildConfig
|
||||
|
||||
import com.example.snapandsolve.camera.AlbumViewState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class AlbumViewModel(private val coroutineContext: CoroutineContext
|
||||
): ViewModel() {
|
||||
/**
|
||||
* ViewModel für Bild-Verwaltung (Kamera + Galerie).
|
||||
* Verwaltet ausgewählte Bilder und temporäre Kamera-Dateien.
|
||||
*/
|
||||
class AlbumViewModel(private val coroutineContext: CoroutineContext) : ViewModel() {
|
||||
|
||||
//region View State
|
||||
private val _albumViewState: MutableStateFlow<AlbumViewState> = MutableStateFlow(
|
||||
AlbumViewState()
|
||||
)
|
||||
|
||||
/**
|
||||
* Observable State für UI-Komponenten.
|
||||
*/
|
||||
val viewStateFlow: StateFlow<AlbumViewState>
|
||||
get() = _albumViewState
|
||||
//endregion
|
||||
|
||||
// region Intents
|
||||
/**
|
||||
* Verarbeitet Benutzer-Aktionen.
|
||||
* @param intent Die zu verarbeitende Aktion
|
||||
*/
|
||||
fun onReceive(intent: Intent) = viewModelScope.launch(coroutineContext) {
|
||||
when(intent) {
|
||||
when (intent) {
|
||||
is Intent.OnPermissionGrantedWith -> {
|
||||
println("DEBUG: OnPermissionGrantedWith empfangen")
|
||||
val tempFile = File.createTempFile(
|
||||
@@ -40,7 +48,8 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
|
||||
)
|
||||
println("DEBUG: TempFile erstellt: ${tempFile.absolutePath}")
|
||||
|
||||
val uri = FileProvider.getUriForFile(intent.compositionContext,
|
||||
val uri = FileProvider.getUriForFile(
|
||||
intent.compositionContext,
|
||||
"${BuildConfig.APPLICATION_ID}.provider",
|
||||
tempFile
|
||||
)
|
||||
@@ -50,13 +59,11 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
|
||||
}
|
||||
|
||||
is Intent.OnPermissionDenied -> {
|
||||
// maybe log the permission denial event
|
||||
println("User did not grant permission to use the camera")
|
||||
}
|
||||
|
||||
is Intent.OnFinishPickingImagesWith -> {
|
||||
if (intent.imageUrls.isNotEmpty()) {
|
||||
// Handle picked images
|
||||
val newImages = mutableListOf<ImageBitmap>()
|
||||
for (eachImageUrl in intent.imageUrls) {
|
||||
val inputStream = intent.compositionContext.contentResolver.openInputStream(eachImageUrl)
|
||||
@@ -69,7 +76,6 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
|
||||
val bitmap: Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bitmapOptions)
|
||||
newImages.add(bitmap.asImageBitmap())
|
||||
} else {
|
||||
// error reading the bytes from the image url
|
||||
println("The image that was picked could not be read from the device at this url: $eachImageUrl")
|
||||
}
|
||||
}
|
||||
@@ -80,40 +86,37 @@ class AlbumViewModel(private val coroutineContext: CoroutineContext
|
||||
tempFileUrl = null
|
||||
)
|
||||
_albumViewState.value = newCopy
|
||||
} else {
|
||||
// user did not pick anything
|
||||
}
|
||||
}
|
||||
|
||||
is Intent.OnImageSavedWith -> {
|
||||
val tempImageUrl = _albumViewState.value.tempFileUrl
|
||||
if (tempImageUrl != null) {
|
||||
val source = ImageDecoder.createSource(intent.compositionContext.contentResolver, tempImageUrl)
|
||||
val source = ImageDecoder.createSource(
|
||||
intent.compositionContext.contentResolver,
|
||||
tempImageUrl
|
||||
)
|
||||
|
||||
val currentPictures = _albumViewState.value.selectedPictures.toMutableList()
|
||||
currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap())
|
||||
|
||||
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null,
|
||||
selectedPictures = currentPictures)
|
||||
_albumViewState.value = _albumViewState.value.copy(
|
||||
tempFileUrl = null,
|
||||
selectedPictures = currentPictures
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Intent.OnImageSavingCanceled -> {
|
||||
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null)
|
||||
}
|
||||
|
||||
is Intent.OnPermissionGranted -> {
|
||||
// unnecessary in this viewmodel variant
|
||||
}
|
||||
|
||||
is Intent.OnFinishPickingImages -> {
|
||||
// unnecessary in this viewmodel variant
|
||||
}
|
||||
|
||||
is Intent.OnImageSaved -> {
|
||||
// unnecessary in this viewmodel variant
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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(
|
||||
/**
|
||||
* holds the URL of the temporary file which stores the image taken by the camera.
|
||||
* Speichert die URL der temporären Datei des Kamerabildes.
|
||||
*/
|
||||
val tempFileUrl: Uri? = null,
|
||||
|
||||
/**
|
||||
* holds the list of images taken by camera or selected pictures from the gallery.
|
||||
* Speichert eine Liste der Bilder, die entweder über die Kamera aufgenommen oder aus der Galerie ausgewählt wurden.
|
||||
*/
|
||||
val selectedPictures: List<ImageBitmap> = emptyList(),
|
||||
)
|
||||
@@ -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.core.tween
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.example.snapandsolve.ui.theme.WidgetColor
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -42,9 +42,7 @@ class LocationHelper(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Composable zum Einrichten des Location Display
|
||||
*
|
||||
* @param autoPanMode Wie die Karte dem Standort folgen soll (default: Recenter)
|
||||
* @return LocationDisplay-Objekt, das an MapView übergeben werden kann
|
||||
* Beim Auslagern wird diese Funktion nicht mehr genutzt. Das sollte gefixt werden!!!!!!!!!!!!!!
|
||||
*/
|
||||
@Composable
|
||||
fun setupLocationDisplay(
|
||||
|
||||
@@ -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"
|
||||
material3 = "1.4.0"
|
||||
arcgisMapsKotlin = "200.8.0"
|
||||
runtime = "1.10.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -31,6 +32,7 @@ arcgis-maps-kotlin = { group = "com.esri", name = "arcgis-maps-kotlin", version.
|
||||
arcgis-maps-kotlin-toolkit-bom = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-bom", version.ref = "arcgisMapsKotlin" }
|
||||
arcgis-maps-kotlin-toolkit-geoview-compose = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-geoview-compose" }
|
||||
arcgis-maps-kotlin-toolkit-authentication = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-authentication" }
|
||||
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user