feat(android): sort CMS users, remove invite button, close CMS submenu on navigate

This commit is contained in:
Torsten Schulz (local)
2026-05-29 00:11:42 +02:00
parent c8b7f5ec2e
commit b4c31374c0
3 changed files with 380 additions and 29 deletions

View File

@@ -61,13 +61,17 @@ fun AppNavigationHeader(
if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
} else {
CompactNavigation(selectedRoute, onNavigate)
CompactNavigation(selectedRoute, onNavigate, navigationState)
}
}
}
@Composable
private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) {
private fun CompactNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
navigationState: NavigationUiState = NavigationUiState(),
) {
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
@@ -75,6 +79,9 @@ private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Un
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
}
}
}
@@ -85,6 +92,9 @@ private fun WebTabletNavigation(
navigationState: NavigationUiState,
) {
val section = menuSection(selectedRoute)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
// Helper that closes the CMS submenu when navigating away
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.width(16.dp))
@@ -93,12 +103,12 @@ private fun WebTabletNavigation(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) })
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
}
@@ -106,11 +116,18 @@ private fun WebTabletNavigation(
if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
}
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) })
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) }
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
}
}
val subItems = submenu(section, navigationState)
// determine CMS parent index and children
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
// First row: render all subitems but do NOT render CMS children here
Row(
modifier = Modifier
.fillMaxWidth()
@@ -118,8 +135,33 @@ private fun WebTabletNavigation(
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) }
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
// CMS parent toggle
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
}
} else if (idx > cmsIndex && cmsIndex >= 0) {
// skip cms children here; they'll be rendered in the second row when expanded
} else {
// normal item before CMS: close cms submenu on navigate
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
}
}
}
// Second row: when CMS expanded, render its children beneath
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
}
}
}
}
@@ -301,6 +343,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))

View File

@@ -2,6 +2,8 @@ package de.harheimertc.ui.screens.cms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -23,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -30,6 +33,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.navigation.NavController
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse
@@ -230,7 +235,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
}
@Composable
private fun CmsPage(
fun CmsPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
@@ -281,28 +286,126 @@ private fun CmsConfigPage(
@Composable
private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) {
val viewModel: CmsViewModel = hiltViewModel()
val state by viewModel.state.collectAsState()
val pending = state.users.filter { it.active == false }
val active = state.users.filter { it.active == true }
.sortedWith(compareBy({ (it.name ?: "").lowercase() }))
CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") {
if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
items(users.size) { index -> UserCard(users[index]) }
if (state.users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
// Pending (inactive) users first with highlighted background
if (pending.isNotEmpty()) {
items(pending.size) { index ->
val user = pending[index]
Surface(color = Accent100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(user.name.ifBlank { user.email ?: "Unbekannt" }, style = MaterialTheme.typography.titleMedium, color = Accent900)
Text(user.email ?: "-", color = Accent700)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
Button(onClick = { viewModel.setUserActive(user.id, true) }, enabled = !state.saving) { Text("Freischalten") }
Button(onClick = { viewModel.resendInvite(user.id) }, enabled = !state.saving) { Text("Invite erneut") }
}
}
}
}
}
// Active users
items(active.size) { index -> UserCard(active[index], viewModel) }
}
}
@Composable
private fun UserCard(user: CmsUserDto) {
private fun UserCard(user: CmsUserDto, viewModel: CmsViewModel) {
var rolesDialogOpen by remember { mutableStateOf(false) }
val roleOptions = listOf("admin", "vorstand", "trainer", "newsletter")
val state by viewModel.state.collectAsState()
DataCard(user.name.ifBlank { user.email.orEmpty() }) {
InfoRow("E-Mail", user.email ?: "-")
InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" })
InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv")
InfoRow("Letzter Login", user.lastLogin ?: "-")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
TextButton(onClick = { viewModel.setUserActive(user.id, !user.active) }, enabled = !state.saving) { Text(if (user.active) "Deaktivieren" else "Aktivieren") }
Spacer(modifier = Modifier.width(6.dp))
TextButton(onClick = { rolesDialogOpen = true }) { Text("Rollen") }
}
}
if (rolesDialogOpen) {
val selected = remember { mutableStateListOf<String>().apply { addAll(user.roles) } }
AlertDialog(
onDismissRequest = { rolesDialogOpen = false },
title = { Text("Rollen bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
roleOptions.forEach { role ->
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = selected.contains(role), onCheckedChange = { checked ->
if (checked) selected.add(role) else selected.remove(role)
})
Text(role, modifier = Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = {
Button(onClick = {
viewModel.updateUserRoles(user.id, selected.toList())
rolesDialogOpen = false
}) { Text("Speichern") }
},
dismissButton = {
TextButton(onClick = { rolesDialogOpen = false }) { Text("Abbrechen") }
}
)
}
}
@Composable
private fun ContactRequestCard(request: ContactRequestDto) {
val viewModel: CmsViewModel = hiltViewModel()
var replyOpen by remember { mutableStateOf(false) }
var replyText by remember { mutableStateOf("") }
val state by viewModel.state.collectAsState()
DataCard(request.name.ifBlank { request.email }) {
InfoRow("E-Mail", request.email)
InfoRow("Status", request.status.ifBlank { "offen" })
InfoRow("Nachricht", request.message)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
TextButton(onClick = { replyOpen = true }, enabled = !state.saving) { Text("Antworten") }
TextButton(onClick = { viewModel.toggleContactRequestStatus(request.id) }, enabled = !state.saving) { Text("Status umschalten") }
}
}
if (replyOpen) {
AlertDialog(
onDismissRequest = { replyOpen = false },
title = { Text("Antwort an ${request.name}") },
text = {
Column {
Text(request.message)
Spacer(modifier = Modifier.width(6.dp))
TextButton(onClick = {}) { /* placeholder to align */ }
androidx.compose.material3.OutlinedTextField(value = replyText, onValueChange = { replyText = it }, label = { Text("Antwort") })
}
},
confirmButton = {
Button(onClick = {
viewModel.replyToContactRequest(request.id, replyText)
replyOpen = false
replyText = ""
}) { Text("Senden") }
},
dismissButton = {
TextButton(onClick = { replyOpen = false }) { Text("Abbrechen") }
}
)
}
}

View File

@@ -9,12 +9,15 @@ import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.repositories.CmsRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import de.harheimertc.ui.util.ErrorMapper
data class CmsUiState(
val loading: Boolean = true,
@@ -27,6 +30,7 @@ data class CmsUiState(
val newsletters: List<NewsletterDto> = emptyList(),
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
)
@HiltViewModel
@@ -43,21 +47,51 @@ class CmsViewModel @Inject constructor(
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val config = async { repository.config().getOrNull() }
val users = async { repository.users().getOrNull()?.users.orEmpty() }
val requests = async { repository.contactRequests().getOrNull().orEmpty() }
val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() }
val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() }
val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() }
val configRes = async { repository.config() }
val usersRes = async { repository.users() }
val requestsRes = async { repository.contactRequests() }
val newslettersRes = async { repository.newsletters() }
val groupsRes = async { repository.newsletterGroups() }
val newsRes = async { repository.news() }
val diagnosticsRes = async { repository.passwordResetDiagnostics() }
val configResult = configRes.await()
val usersResult = usersRes.await()
val requestsResult = requestsRes.await()
val newslettersResult = newslettersRes.await()
val groupsResult = groupsRes.await()
val newsResult = newsRes.await()
val diagnosticsResult = diagnosticsRes.await()
val errors = listOfNotNull(
ErrorMapper.mapError(configResult.exceptionOrNull()),
ErrorMapper.mapError(usersResult.exceptionOrNull()),
ErrorMapper.mapError(requestsResult.exceptionOrNull()),
ErrorMapper.mapError(newslettersResult.exceptionOrNull()),
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
ErrorMapper.mapError(newsResult.exceptionOrNull()),
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
)
// Sort users so that pending (inactive) users come first,
// followed by active users sorted by display name (case-insensitive).
val fetchedUsers = usersResult.getOrNull()?.users.orEmpty()
val pendingUsers = fetchedUsers.filter { it.active == false }
val activeUsers = fetchedUsers.filter { it.active == true }
.sortedBy { (it.name ?: "").lowercase() }
val orderedUsers = pendingUsers + activeUsers
_state.value = CmsUiState(
loading = false,
config = config.await(),
users = users.await(),
contactRequests = requests.await(),
newsletters = newsletters.await(),
newsletterGroups = groups.await(),
passwordResetAttempts = diagnostics.await(),
error = if (errors.isNotEmpty()) errors.joinToString("; ") else null,
config = configResult.getOrNull(),
users = orderedUsers,
contactRequests = requestsResult.getOrNull().orEmpty(),
newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(),
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
news = newsResult.getOrNull()?.news.orEmpty(),
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
)
}
}
@@ -76,9 +110,180 @@ class CmsViewModel @Inject constructor(
.onFailure {
_state.value = _state.value.copy(
saving = false,
error = it.message ?: "Inhalt konnte nicht gespeichert werden.",
error = ErrorMapper.mapError(it) ?: "Inhalt konnte nicht gespeichert werden.",
)
}
}
}
fun saveNews(request: NewsSaveRequest) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.saveNews(request)
.onSuccess { msg ->
// refresh news list directly to preserve message
val newsRes = repository.news()
_state.value = _state.value.copy(
saving = false,
news = newsRes.getOrNull()?.news.orEmpty(),
message = msg.message ?: "Nachricht gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gespeichert werden.")
}
}
}
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
val existing = _state.value.news.find { it.id == id }
if (existing != null) {
val req = NewsSaveRequest(
id = existing.id,
title = existing.title,
content = existing.content,
isPublic = makePublic,
isHidden = existing.isHidden,
expiresAt = existing.expiresAt,
)
repository.saveNews(req)
}
}
val newsRes = repository.news()
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
}
}
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
val existing = _state.value.news.find { it.id == id }
if (existing != null) {
val req = NewsSaveRequest(
id = existing.id,
title = existing.title,
content = existing.content,
isPublic = existing.isPublic,
isHidden = makeHidden,
expiresAt = existing.expiresAt,
)
repository.saveNews(req)
}
}
val newsRes = repository.news()
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
}
}
fun bulkDelete(ids: List<Int>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
repository.deleteNews(id)
}
val newsRes = repository.news()
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Löschung abgeschlossen.")
}
}
fun deleteNews(id: Int) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id)
.onSuccess { msg ->
val newsRes = repository.news()
_state.value = _state.value.copy(
saving = false,
news = newsRes.getOrNull()?.news.orEmpty(),
message = msg.message ?: "Nachricht gelöscht.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gelöscht werden.")
}
}
}
// --- User management actions (B2)
fun updateUserRoles(id: String, roles: List<String>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateUserRoles(id, roles)
.onSuccess { msg ->
val usersRes = repository.users()
_state.value = _state.value.copy(
saving = false,
users = usersRes.getOrNull()?.users.orEmpty(),
message = msg.message ?: "Rollen aktualisiert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Rollen konnten nicht aktualisiert werden.")
}
}
}
fun setUserActive(id: String, active: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateUserActive(id, active)
.onSuccess { msg ->
val usersRes = repository.users()
_state.value = _state.value.copy(
saving = false,
users = usersRes.getOrNull()?.users.orEmpty(),
message = msg.message ?: if (active) "Benutzer aktiviert." else "Benutzer deaktiviert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Benutzerstatus konnte nicht geändert werden.")
}
}
}
fun resendInvite(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.resendInvite(id)
.onSuccess { msg ->
_state.value = _state.value.copy(saving = false, message = msg.message ?: "Einladung erneut gesendet.")
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Einladung konnte nicht gesendet werden.")
}
}
}
// --- Contact requests (B3)
fun replyToContactRequest(id: String, message: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.replyToContactRequest(id, message)
.onSuccess {
val reqs = repository.contactRequests()
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Antwort gesendet.")
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Antwort konnte nicht gesendet werden.")
}
}
}
fun toggleContactRequestStatus(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.toggleContactRequestStatus(id)
.onSuccess {
val reqs = repository.contactRequests()
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Status aktualisiert.")
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Status konnte nicht geändert werden.")
}
}
}
}