- Bewertungsystem

- Filtern nach Schäden
- Docs Ordner mit Markdown dummies gefüllt.
This commit is contained in:
2026-01-21 21:37:58 +01:00
parent 33e95641d0
commit 05426b687c
21 changed files with 642 additions and 29 deletions

View File

@@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.AddLocationAlt
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.ThumbUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@@ -48,6 +49,8 @@ import com.example.snapandsolve.camera.AlbumViewState
import com.example.snapandsolve.camera.Intent
import com.example.snapandsolve.ui.theme.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun MainScreen(modifier: Modifier = Modifier, application: Application) {
@@ -124,15 +127,20 @@ fun ContentScreen(
sliderOpen: Boolean,
onDismissReport: () -> Unit
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
// NEU: State für Filter-Dialog
var showFilterDialog by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) {
// HINTERGRUND: Die Map
// Map
MapSegment(
modifier = Modifier.fillMaxSize(),
mapViewModel = mapViewModel,
mapViewModel = mapViewModel
)
// VORDERGRUND: Das Overlay (wenn showReport = true)
// Report Overlay
if (showReport) {
ReportOverlay(
onCancel = onDismissReport,
@@ -142,15 +150,34 @@ fun ContentScreen(
)
}
if (mapViewModel.showFeatureInfo && mapViewModel.selectedFeature != null) {
FeatureInfoOverlay(
feature = mapViewModel.selectedFeature!!,
onClose = { mapViewModel.closeFeatureInfo() }
// 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 - NEU!
if (showFilterDialog) {
DamageFilterDialog(
damageTypes = MapViewModel.DAMAGE_TYPES, // <-- Nutzt zentrale Liste
currentFilters = mapViewModel.getActiveFilters(),
onDismiss = { showFilterDialog = false },
onApplyFilter = { selectedTypes ->
coroutineScope.launch {
mapViewModel.applyDamageFilter(selectedTypes)
}
}
)
}
// Slider von Links
// Side Slider
SideSlider(visible = sliderOpen) {
Text(
"Menü",
@@ -162,16 +189,14 @@ fun ContentScreen(
text = "Schäden filtern",
icon = Icons.Default.FilterAlt,
onClick = {
/* TODO */
showFilterDialog = true // <-- Öffnet Filter-Dialog
}
)
SliderMenuItem(
text = "Schadensliste",
icon = Icons.Default.FormatListNumbered,
onClick = {
/* TODO */
}
onClick = { /* TODO */ }
)
}
}
@@ -275,7 +300,7 @@ fun ReportOverlay(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
listOf("Straße", "Gehweg", "Fahrradweg", "Beleuchtung", "Sonstiges").forEach { typ ->
MapViewModel.DAMAGE_TYPES.forEach { typ -> // <-- Nutzt zentrale Liste
DropdownMenuItem(
text = { Text(typ) },
onClick = {

View File

@@ -9,10 +9,8 @@ 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.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -20,8 +18,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.arcgismaps.ApiKey
import com.arcgismaps.ArcGISEnvironment
import com.arcgismaps.Color
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.location.LocationDisplayAutoPanMode
import com.arcgismaps.toolkit.geoviewcompose.MapView
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
@@ -31,6 +27,7 @@ import kotlinx.coroutines.launch
fun MapSegment(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
@@ -64,8 +61,7 @@ fun MapSegment(
}
Column(
modifier = modifier
.fillMaxSize(),
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MapView(
@@ -73,11 +69,9 @@ fun MapSegment(
arcGISMap = mapViewModel.map,
locationDisplay = locationDisplay,
mapViewProxy = mapViewModel.mapViewProxy,
onSingleTapConfirmed = mapViewModel::onTap,
onSingleTapConfirmed = mapViewModel::onTap, // Ganz normal
graphicsOverlays = listOf(mapViewModel.tempOverlay)
) {
/* TODO */
}
)
}
}
@@ -125,4 +119,4 @@ fun RequestPermissions(context: Context, onPermissionsGranted: () -> Unit) {
)
)
}
}
}

View File

@@ -33,11 +33,21 @@ import java.io.ByteArrayOutputStream
class MapViewModel(application: Application) : 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)
}
var selectedOperation by mutableStateOf(FeatureOperationType.DEFAULT)
private set
var reopenReport by mutableStateOf(false)
private set
var showFeatureInfo by mutableStateOf(false)
@@ -64,7 +74,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
init {
tempOverlay.graphics.add(pointGraphic)
viewModelScope.launch {
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/arcgis/rest/services/251120_StrassenSchaeden/FeatureServer/0")
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/arcgis/rest/services/si_StrassenSchaeden/FeatureServer/0")
serviceFeatureTable.load().onSuccess {
val typeDamageField = serviceFeatureTable.fields.firstOrNull { it.name == "Typ" }
val attributeDomain = typeDamageField?.domain as? CodedValueDomain
@@ -175,7 +185,6 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
}
fun onTap(singleTapConfirmedEvent: SingleTapConfirmedEvent) {
if (featureLayer.loadStatus.value != LoadStatus.Loaded) {
snackBarMessage = "Layer not loaded!"
return
@@ -187,7 +196,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
FeatureOperationType.UPDATE_ATTRIBUTE -> selectFeatureForAttributeEditAt(singleTapConfirmedEvent.screenCoordinate)
FeatureOperationType.UPDATE_GEOMETRY -> updateFeatureGeometryAt(singleTapConfirmedEvent.screenCoordinate)
FeatureOperationType.PICK_REPORT_LOCATION -> pickReportLocation(singleTapConfirmedEvent.screenCoordinate)
else -> {}
// RATE_FEATURE wird nicht mehr gebraucht - wird im DEFAULT mit behandelt!
}
}
@@ -250,7 +259,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
// When a feature is selected, update its geometry to the tapped location.
else -> {
mapViewProxy.screenToLocationOrNull(screenCoordinate)?.let { mapPoint ->
// Normalize the point - needed when the tapped location is over the international date line.

View File

@@ -0,0 +1,248 @@
package com.example.snapandsolve.ui.theme
import MapViewModel
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
/**
* Dialog für Schaden-Filter
* Ermöglicht das Filtern von Features nach Typ
*/
@Composable
fun DamageFilterDialog(
damageTypes: List<String>,
currentFilters: Set<String>,
onDismiss: () -> Unit,
onApplyFilter: (Set<String>) -> Unit
) {
var selectedFilters by remember { mutableStateOf(currentFilters) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.7f),
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))
// Info-Text
Text(
text = "Wähle die Schadenstypen aus, die angezeigt werden sollen:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
// Filter-Liste
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
damageTypes.forEach { type ->
FilterCheckboxItem(
label = type,
isChecked = selectedFilters.contains(type),
onCheckedChange = { isChecked ->
selectedFilters = if (isChecked) {
selectedFilters + type
} else {
selectedFilters - type
}
}
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Info über aktive Filter
if (selectedFilters.isNotEmpty()) {
Text(
text = "${selectedFilters.size} von ${damageTypes.size} Typen ausgewählt",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Alle auswählen
OutlinedButton(
onClick = { selectedFilters = damageTypes.toSet() },
modifier = Modifier.weight(1f)
) {
Text("Alle")
}
// Alle abwählen
OutlinedButton(
onClick = { selectedFilters = emptySet() },
modifier = Modifier.weight(1f)
) {
Text("Keine")
}
}
Spacer(modifier = Modifier.height(12.dp))
// Anwenden Button
Button(
onClick = {
onApplyFilter(selectedFilters)
onDismiss()
},
modifier = Modifier.fillMaxWidth()
) {
Text(
if (selectedFilters.isEmpty()) "Alle anzeigen"
else "Filter anwenden"
)
}
}
}
}
}
/**
* Checkbox-Item für einen Filter
*/
@Composable
fun FilterCheckboxItem(
label: String,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
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 = when (label) {
"Straße" -> "🛣️"
"Gehweg" -> "🚶"
"Fahrradweg" -> "🚴"
"Beleuchtung" -> "💡"
"Sonstiges" -> "📍"
else -> ""
},
style = MaterialTheme.typography.headlineSmall
)
}
}
/**
* Extension-Funktion für MapViewModel
* Wendet Filter auf den FeatureLayer an
*/
suspend fun MapViewModel.applyDamageFilter(selectedTypes: Set<String>): Boolean {
return withContext(Dispatchers.IO) {
try {
if (selectedTypes.isEmpty()) {
// Kein Filter - zeige alle Features
featureLayer.definitionExpression = ""
println("DEBUG: Filter entfernt - zeige alle Features")
} else {
// Erstelle WHERE-Klausel für SQL
// Beispiel: "Typ IN ('Straße', 'Gehweg')"
val typeList = selectedTypes.joinToString("', '", "'", "'")
val whereClause = "Typ IN ($typeList)"
featureLayer.definitionExpression = whereClause
println("DEBUG: Filter angewendet: $whereClause")
}
withContext(Dispatchers.Main) {
snackBarMessage = if (selectedTypes.isEmpty()) {
"Alle Schäden werden angezeigt"
} else {
"${selectedTypes.size} Typ(en) gefiltert"
}
}
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
}
}
}
/**
* Extension-Funktion für MapViewModel
* Gibt die aktuell aktiven Filter zurück
*/
fun MapViewModel.getActiveFilters(): Set<String> {
val expression = featureLayer.definitionExpression
if (expression.isEmpty()) return emptySet()
val regex = "'([^']+)'".toRegex()
return regex.findAll(expression)
.map { it.groupValues[1] }
.toSet()
}

View File

@@ -0,0 +1,337 @@
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 com.arcgismaps.data.Attachment
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 attachments by remember { mutableStateOf<List<Attachment>>(emptyList()) }
var photoBitmaps by remember { mutableStateOf<List<androidx.compose.ui.graphics.ImageBitmap>>(emptyList()) }
var isLoadingPhotos by remember { mutableStateOf(true) }
// Lade Fotos beim Öffnen
LaunchedEffect(feature) {
try {
// Lade Feature falls nötig
if (feature.loadStatus.value != com.arcgismaps.LoadStatus.Loaded) {
feature.load().getOrNull()
}
// Hole Attachments
feature.fetchAttachments().onSuccess { fetchedAttachments ->
attachments = fetchedAttachments
// Lade Foto-Daten
val bitmaps = 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) {
bitmaps.add(bitmap.asImageBitmap())
}
} catch (e: Exception) {
println("DEBUG: Fehler beim Laden des Bildes: ${e.message}")
}
}
}
photoBitmaps = bitmaps
}
} 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)
)
Divider(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) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Lade 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
)
}
}
}
// Info-Text
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
OutlinedButton(
onClick = {
onRate(feature, false)
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 8.dp)
) {
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,
modifier = Modifier.padding(vertical = 8.dp)
) {
Text("👍", style = MaterialTheme.typography.headlineMedium)
Text("Ja", style = MaterialTheme.typography.bodyMedium)
}
}
}
// Schließen Button
TextButton(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text("Schließen")
}
}
}
}
}
/**
* Extension-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 {
// Hole aktuelle Bewertung
val currentRating = (feature.attributes["communitycounter"] as? Number)?.toInt() ?: 0
// Berechne neue Bewertung
val newRating = if (isPositive) {
currentRating + 1
} else {
maxOf(0, currentRating - 1) // Verhindert negative Werte
}
println("DEBUG: Rating-Update: $currentRating -> $newRating (${if(isPositive) "+" else "-"})")
// Aktualisiere Feature-Attribut
feature.attributes["communitycounter"] = newRating
// Speichere in Feature Table
serviceFeatureTable.updateFeature(feature).onSuccess {
println("DEBUG: updateFeature erfolgreich")
// Synchronisiere mit ArcGIS Online
serviceFeatureTable.applyEdits().onSuccess {
println("DEBUG: applyEdits erfolgreich - Rating gespeichert")
withContext(Dispatchers.Main) {
val message = if (isPositive) {
"✓ Schaden bestätigt! (${currentRating}${newRating})"
} else {
"✓ Bewertung verringert (${currentRating}${newRating})"
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
snackBarMessage = message
}
}.onFailure { error ->
println("DEBUG: applyEdits fehlgeschlagen: ${error.message}")
withContext(Dispatchers.Main) {
Toast.makeText(context, "Sync-Fehler: ${error.message}", Toast.LENGTH_LONG).show()
}
}
}.onFailure { error ->
println("DEBUG: updateFeature fehlgeschlagen: ${error.message}")
withContext(Dispatchers.Main) {
Toast.makeText(context, "Update-Fehler: ${error.message}", Toast.LENGTH_LONG).show()
}
}
true
} catch (e: Exception) {
println("DEBUG: Rating-Update Exception: ${e.message}")
e.printStackTrace()
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Fehler beim Bewerten: ${e.message}",
Toast.LENGTH_LONG
).show()
}
false
}
}
}