Enhance Veranstalter and Veranstaltung flows: add confirm dialog for event creation, refine navigation logic, and improve onboarding with keyboard focus handling.
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled

This commit is contained in:
Stefan Mogeritsch 2026-04-02 00:40:43 +02:00
parent b990f4dc05
commit d3d80f6995
11 changed files with 587 additions and 120 deletions

View File

@ -21,10 +21,5 @@ kotlin {
implementation(kotlin("test"))
}
}
val jvmTest by getting {
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}

View File

@ -72,7 +72,7 @@ class AltersklasseRechnerTest {
}
@Test
fun `ermittleAltersklassen berücksichtigt SpartenFilter`() {
fun `ermittleAltersklassen beruecksichtigt SpartenFilter`() {
val reiter = DomReiter(
personId = Uuid.random(),
satznummer = "123456",

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -17,6 +17,9 @@ private class FakeNav : NavigationPort {
override fun navigateToScreen(screen: AppScreen) {
last = screen.route
}
override fun navigateBack() {
// no-op for tests
}
}
private class FakeUserProvider(private val user: User?) : CurrentUserProvider {

View File

@ -35,8 +35,7 @@ enum class Sparte(val label: String) {
SPRINGEN("Springen"),
VIELSEITIGKEIT("Vielseitigkeit"),
VOLTIGIEREN("Voltigieren"),
FAHREN("Fahren"),
REINING("Reining")
FAHREN("Fahren")
}
enum class ReiterStatus(val label: String, val color: Color) {

View File

@ -5,6 +5,8 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.singleWindowApplication
import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
/**
* Hot-Reload Preview Entry Point
@ -27,7 +29,7 @@ private fun PreviewContent() {
Surface {
// --- REITER ---
// ReiterScreen(viewModel = ReiterViewModel())
ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE ---
// PferdeScreen(viewModel = PferdeViewModel())

View File

@ -11,8 +11,11 @@ import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
@ -56,6 +59,10 @@ fun DesktopMainLayout(
onBack: () -> Unit,
onLogout: () -> Unit,
) {
// Onboarding-Eingaben zwischen Navigationswechseln behalten → State hier (außerhalb des when) hosten
var obGeraet by rememberSaveable { mutableStateOf("") }
var obKey by rememberSaveable { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize()) {
DesktopTopBar(
currentScreen = currentScreen,
@ -69,6 +76,10 @@ fun DesktopMainLayout(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
obGeraet = obGeraet,
obKey = obKey,
onObGeraetChange = { obGeraet = it },
onObKeyChange = { obKey = it },
)
}
DesktopFooterBar()
@ -102,8 +113,8 @@ private fun DesktopTopBar(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Zurück-Pfeil (nur wenn nicht Root)
if (currentScreen !is AppScreen.VeranstaltungVerwaltung) {
// Zurück-Pfeil: für alle außer Onboarding anzeigen (damit man von "Verwaltung" zurück kommt)
if (currentScreen !is AppScreen.Onboarding) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
@ -138,10 +149,10 @@ private fun DesktopTopBar(
is AppScreen.VeranstalterNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterAuswahl) },
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
@ -154,10 +165,10 @@ private fun DesktopTopBar(
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterAuswahl) },
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
@ -170,10 +181,10 @@ private fun DesktopTopBar(
is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
text = "Veranstalter-Verwaltung",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterAuswahl) },
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
@ -335,12 +346,21 @@ private fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
obGeraet: String,
obKey: String,
onObGeraetChange: (String) -> Unit,
onObKeyChange: (String) -> Unit,
) {
when (currentScreen) {
// Onboarding ohne Login
is AppScreen.Onboarding -> {
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
at.mocode.desktop.v2.OnboardingScreen { _, _ ->
at.mocode.desktop.v2.OnboardingScreen(
geraetName = obGeraet,
secureKey = obKey,
onGeraetNameChange = onObGeraetChange,
onSecureKeyChange = onObKeyChange,
) { _, _ ->
authTokenManager.setToken("dummy.jwt.token")
onNavigate(AppScreen.VeranstaltungVerwaltung)
}
@ -373,11 +393,9 @@ private fun DesktopContentArea(
onEdit = { onNavigate(AppScreen.PferdProfil(it)) }
)
is AppScreen.PferdProfil -> PlaceholderScreen(
"Pferde-Profil #${currentScreen.id}",
is AppScreen.PferdProfil -> at.mocode.desktop.v2.PferdProfilV2(
id = currentScreen.id,
onBack = onBack,
onAction = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Reiter-Verwaltung & Profil ---
@ -386,11 +404,9 @@ private fun DesktopContentArea(
onEdit = { onNavigate(AppScreen.ReiterProfil(it)) }
)
is AppScreen.ReiterProfil -> PlaceholderScreen(
"Reiter-Profil #${currentScreen.id}",
is AppScreen.ReiterProfil -> at.mocode.desktop.v2.ReiterProfilV2(
id = currentScreen.id,
onBack = onBack,
onAction = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Verein-Verwaltung & Profil ---
@ -399,11 +415,9 @@ private fun DesktopContentArea(
onEdit = { onNavigate(AppScreen.VereinProfil(it)) }
)
is AppScreen.VereinProfil -> PlaceholderScreen(
"Verein-Profil #${currentScreen.id}",
is AppScreen.VereinProfil -> at.mocode.desktop.v2.VereinProfilV2(
id = currentScreen.id,
onBack = onBack,
onAction = { onNavigate(AppScreen.VereinVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Funktionaer-Verwaltung & Profil ---
@ -412,24 +426,23 @@ private fun DesktopContentArea(
onEdit = { onNavigate(AppScreen.FunktionaerProfil(it)) }
)
is AppScreen.FunktionaerProfil -> PlaceholderScreen(
"Funktionär-Profil #${currentScreen.id}",
is AppScreen.FunktionaerProfil -> at.mocode.desktop.v2.FunktionaerProfilV2(
id = currentScreen.id,
onBack = onBack,
onAction = { onNavigate(AppScreen.FunktionaerVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Veranstalter-Verwaltung & Profil ---
is AppScreen.VeranstalterVerwaltung -> at.mocode.desktop.v2.VeranstalterVerwaltungScreen(
onBack = onBack,
onNew = { onNavigate(AppScreen.VeranstalterNeu) },
onEdit = { onNavigate(AppScreen.VeranstalterProfil(it)) }
)
is AppScreen.VeranstalterProfil -> PlaceholderScreen(
"Veranstalter-Profil #${currentScreen.id}",
is AppScreen.VeranstalterProfil -> at.mocode.desktop.v2.VeranstalterDetailV2(
veranstalterId = currentScreen.id,
onBack = onBack,
onAction = { onNavigate(AppScreen.PferdProfil(1L)) },
actionLabel = "Pferde-Profil öffnen"
onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungProfil(currentScreen.id, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(currentScreen.id)) },
)
/*
@ -445,9 +458,9 @@ private fun DesktopContentArea(
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
)
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
onAbbrechen = onBack,
onSpeichern = { _, _, _ -> onBack() },
is AppScreen.VeranstalterNeu -> at.mocode.desktop.v2.VeranstalterAnlegenWizard(
onCancel = onBack,
onVereinCreated = { newId -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
)
is AppScreen.VeranstalterDetail -> {
val vId = currentScreen.veranstalterId

View File

@ -5,8 +5,10 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -35,7 +37,7 @@ fun <T> ManagementTableScreen(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Text(title, style = MaterialTheme.typography.headlineMedium)
}
@ -87,7 +89,7 @@ fun <T> ManagementTableScreen(
}
}
Divider()
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
// Table Body
LazyColumn(modifier = Modifier.fillMaxSize()) {
@ -121,7 +123,7 @@ fun <T> ManagementTableScreen(
}
}
}
Divider()
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
}
}
}
@ -248,7 +250,7 @@ fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
}
@Composable
fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onNew: () -> Unit, onEdit: (Long) -> Unit) {
// Veranstalter sind in unserem System eigentlich Vereine, die Veranstaltungen ausrichten
// Wir nutzen hier die 'vereine' Liste aus dem Store.
val vereine = StoreV2.vereine
@ -268,7 +270,7 @@ fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
TableColumn("Email", { it.email ?: "-" }, weight = 1f)
),
onBack = onBack,
onNew = { },
onNew = onNew,
onEdit = { onEdit(it.id) },
onDelete = { },
onSearch = { filter = it }

View File

@ -1,12 +1,14 @@
package at.mocode.desktop.v2
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
@ -14,30 +16,66 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
@Composable
fun OnboardingScreen(onContinue: (String, String) -> Unit) {
fun OnboardingScreen(
geraetName: String,
secureKey: String,
onGeraetNameChange: (String) -> Unit,
onSecureKeyChange: (String) -> Unit,
onContinue: (String, String) -> Unit,
) {
DesktopThemeV2 {
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
var geraetName by remember { mutableStateOf("") }
var key by remember { mutableStateOf("") }
var showPw by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val frName = remember { FocusRequester() }
val frKey = remember { FocusRequester() }
val frBtn = remember { FocusRequester() }
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
onValueChange = { onGeraetNameChange(it) },
label = { Text("Gerätename (Pflicht)") },
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.focusRequester(frName)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when (e.key) {
Key.Tab, Key.Enter -> {
focusManager.moveFocus(FocusDirection.Next)
true
}
else -> false
}
} else false
}
,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
OutlinedTextField(
value = key,
onValueChange = { key = it },
value = secureKey,
onValueChange = { onSecureKeyChange(it) },
label = { Text("Sicherheitsschlüssel (Pflicht)") },
trailingIcon = {
IconButton(onClick = { showPw = !showPw }) {
@ -45,12 +83,53 @@ fun OnboardingScreen(onContinue: (String, String) -> Unit) {
}
},
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.focusRequester(frKey)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when (e.key) {
Key.Tab -> {
focusManager.moveFocus(FocusDirection.Next)
true
}
Key.Enter -> {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
} else {
focusManager.moveFocus(FocusDirection.Next)
}
true
}
else -> false
}
} else false
}
,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
} else {
focusManager.moveFocus(FocusDirection.Next)
}
})
)
val enabled = geraetName.trim().length >= 3 && key.trim().length >= 8
Button(onClick = { onContinue(geraetName, key) }, enabled = enabled) {
Text("Weiter zum VeranstalterFlow")
val enabled = geraetName.trim().length >= 3 && secureKey.trim().length >= 8
Button(
onClick = { onContinue(geraetName, secureKey) },
enabled = enabled,
modifier = Modifier
.focusRequester(frBtn)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && (e.key == Key.Enter)) {
if (enabled) onContinue(geraetName, secureKey)
true
} else false
}
) {
Text("Zu den Veranstaltungen")
}
if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020))
}
@ -58,6 +137,298 @@ fun OnboardingScreen(onContinue: (String, String) -> Unit) {
}
}
@Composable
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
if (pferd == null) { Text("Pferd nicht gefunden"); return@DesktopThemeV2 }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
Text("Pferde-Profil", style = MaterialTheme.typography.titleLarge)
}
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(pferd.name, style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
}
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
var name by remember { mutableStateOf(pferd.name) }
var oeps by remember { mutableStateOf(pferd.oepsNummer ?: "") }
var fei by remember { mutableStateOf(pferd.feiId ?: "") }
var geb by remember { mutableStateOf(pferd.geburtsdatum ?: "") }
var farbe by remember { mutableStateOf(pferd.farbe ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
pferd.name = name
pferd.oepsNummer = oeps.ifBlank { null }
pferd.feiId = fei.ifBlank { null }
pferd.geburtsdatum = geb.ifBlank { null }
pferd.farbe = farbe.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Pferd bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("ÖPS-Nr.") }, modifier = Modifier.weight(1f))
OutlinedTextField(fei, { fei = it }, label = { Text("FEI-ID") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(geb, { geb = it }, label = { Text("Geburtsdatum") }, modifier = Modifier.weight(1f))
OutlinedTextField(farbe, { farbe = it }, label = { Text("Farbe") }, modifier = Modifier.weight(1f))
}
}
}
)
}
}
}
}
@Composable
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
if (r == null) { Text("Reiter nicht gefunden"); return@DesktopThemeV2 }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
Text("Reiter-Profil", style = MaterialTheme.typography.titleLarge)
}
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
val initials = (r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(r.oepsNummer?.let { "OEPS: $it" }, r.feiId?.let { "FEI: $it" }, r.lizenzKlasse.takeIf { it.isNotBlank() } ).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
r.verein?.let { Text(it, color = Color(0xFF6B7280)) }
}
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
var vor by remember { mutableStateOf(r.vorname) }
var nach by remember { mutableStateOf(r.nachname) }
var oeps by remember { mutableStateOf(r.oepsNummer ?: "") }
var fei by remember { mutableStateOf(r.feiId ?: "") }
var liz by remember { mutableStateOf(r.lizenzKlasse) }
var verein by remember { mutableStateOf(r.verein ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
r.vorname = vor
r.nachname = nach
r.oepsNummer = oeps.ifBlank { null }
r.feiId = fei.ifBlank { null }
r.lizenzKlasse = liz
r.verein = verein.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Reiter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(vor, { vor = it }, label = { Text("Vorname") }, modifier = Modifier.weight(1f))
OutlinedTextField(nach, { nach = it }, label = { Text("Nachname") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("ÖPS-Nr.") }, modifier = Modifier.weight(1f))
OutlinedTextField(fei, { fei = it }, label = { Text("FEI-ID") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(liz, { liz = it }, label = { Text("Lizenzklasse") }, modifier = Modifier.weight(1f))
OutlinedTextField(verein, { verein = it }, label = { Text("Verein") }, modifier = Modifier.weight(1f))
}
}
}
)
}
}
}
}
@Composable
fun VereinProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
if (v == null) { Text("Verein nicht gefunden"); return@DesktopThemeV2 }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
Text("Vereins-Profil", style = MaterialTheme.typography.titleLarge)
}
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(v.name, style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }.joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
}
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
var name by remember { mutableStateOf(v.name) }
var oeps by remember { mutableStateOf(v.oepsNummer) }
var ort by remember { mutableStateOf(v.ort ?: "") }
var plz by remember { mutableStateOf(v.plz ?: "") }
var strasse by remember { mutableStateOf(v.strasse ?: "") }
var email by remember { mutableStateOf(v.email ?: "") }
var tel by remember { mutableStateOf(v.telefon ?: "") }
var logo by remember { mutableStateOf(v.logoUrl ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
v.name = name
v.oepsNummer = oeps
v.ort = ort.ifBlank { null }
v.plz = plz.ifBlank { null }
v.strasse = strasse.ifBlank { null }
v.email = email.ifBlank { null }
v.telefon = tel.ifBlank { null }
v.logoUrl = logo.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Verein bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(logo, { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(strasse, { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
}
}
}
)
}
}
}
}
@Composable
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 {
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
if (f == null) { Text("Funktionär nicht gefunden"); return@DesktopThemeV2 }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
Text("Funktionärs-Profil", style = MaterialTheme.typography.titleLarge)
}
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
val initials = (f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(f.richterNummer?.let { "Nr. $it" }, f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
f.email?.let { Text(it, color = Color(0xFF6B7280)) }
}
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
var vor by remember { mutableStateOf(f.vorname) }
var nach by remember { mutableStateOf(f.nachname) }
var num by remember { mutableStateOf(f.richterNummer ?: "") }
var qual by remember { mutableStateOf(f.richterQualifikation ?: "") }
var email by remember { mutableStateOf(f.email ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
f.vorname = vor
f.nachname = nach
f.richterNummer = num.ifBlank { null }
f.richterQualifikation = qual.ifBlank { null }
f.email = email.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Funktionär bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(vor, { vor = it }, label = { Text("Vorname") }, modifier = Modifier.weight(1f))
OutlinedTextField(nach, { nach = it }, label = { Text("Nachname") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(qual, { qual = it }, label = { Text("Qualifikation") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
}
}
)
}
}
}
}
@Composable
fun VeranstalterAuswahlV2(
onBack: () -> Unit,
@ -125,62 +496,106 @@ fun VeranstalterDetailV2(
Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") }
}
// Profil-Bereich (Logo URL, Ansprechpartner, Kontakt, Adresse)
// Veranstalter Vorschau-Karte mit Bearbeiten-Dialog
val verein = remember(veranstalterId) { StoreV2.vereine.firstOrNull { it.id == veranstalterId } }
if (verein != null) {
Card {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("VeranstalterProfil", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = verein.logoUrl ?: "",
onValueChange = { verein.logoUrl = it.ifBlank { null } },
label = { Text("LogoURL (optional)") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = verein.ort ?: "",
onValueChange = { verein.ort = it.ifBlank { null } },
label = { Text("Ansprechpartner / Ort (optional)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = verein.telefon ?: "",
onValueChange = { verein.telefon = it.ifBlank { null } },
label = { Text("Telefon (optional)") },
modifier = Modifier.weight(1f)
)
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
// Logo/Avatar
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text((verein.kurzname ?: verein.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = verein.email ?: "",
onValueChange = { verein.email = it.ifBlank { null } },
label = { Text("EMail (optional)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = verein.oepsNummer,
onValueChange = { verein.oepsNummer = it },
label = { Text("OEPSNummer") },
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(verein.name, style = MaterialTheme.typography.titleMedium)
val line2 = listOfNotNull("OEPS: ${verein.oepsNummer}", verein.ort, verein.plz, verein.strasse).filter { it.isNotBlank() }.joinToString(" · ")
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
}
OutlinedTextField(
value = verein.strasse ?: "",
onValueChange = { verein.strasse = it.ifBlank { null } },
label = { Text("Adresse / Straße (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
// Lokale Edit-Felder
var name by remember { mutableStateOf(verein.name) }
var oeps by remember { mutableStateOf(verein.oepsNummer) }
var ort by remember { mutableStateOf(verein.ort ?: "") }
var plz by remember { mutableStateOf(verein.plz ?: "") }
var strasse by remember { mutableStateOf(verein.strasse ?: "") }
var email by remember { mutableStateOf(verein.email ?: "") }
var tel by remember { mutableStateOf(verein.telefon ?: "") }
var logo by remember { mutableStateOf(verein.logoUrl ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
// Speichern in Store
verein.name = name
verein.oepsNummer = oeps
verein.ort = ort.ifBlank { null }
verein.plz = plz.ifBlank { null }
verein.strasse = strasse.ifBlank { null }
verein.email = email.ifBlank { null }
verein.telefon = tel.ifBlank { null }
verein.logoUrl = logo.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Veranstalter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = oeps, onValueChange = { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = logo, onValueChange = { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = ort, onValueChange = { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = plz, onValueChange = { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(value = strasse, onValueChange = { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = tel, onValueChange = { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
}
}
}
)
}
}
val events = StoreV2.eventsFor(veranstalterId)
if (events.isEmpty()) Text("Noch keine Veranstaltungen angelegt.", color = Color(0xFF6B7280))
// Filter-/Suchmaske
var search by remember { mutableStateOf("") }
OutlinedTextField(
value = search,
onValueChange = { search = it },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
placeholder = { Text("Veranstaltungen suchen…") },
modifier = Modifier.fillMaxWidth()
)
val filtered = remember(events, search) {
val q = search.trim()
if (q.isEmpty()) events else events.filter {
it.titel.contains(q, ignoreCase = true) ||
it.status.contains(q, ignoreCase = true) ||
it.datumVon.contains(q, ignoreCase = true) ||
(it.datumBis?.contains(q, ignoreCase = true) == true)
}
}
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
LazyColumn(Modifier.fillMaxSize()) {
items(events) { evt ->
items(filtered) { evt ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {

View File

@ -417,7 +417,13 @@ fun VeranstaltungKonfigV2(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = {
if (currentStep > 1) {
currentStep--
// Wenn wir aus einem konkreten Veranstalter kommen (id > 0),
// gehen wir bei Zurück direkt ins Profil statt auf Schritt 1.
if (veranstalterId != 0L) {
onBack()
} else {
currentStep--
}
} else {
onBack()
}
@ -667,28 +673,60 @@ fun VeranstaltungKonfigV2(
Spacer(Modifier.width(1.dp))
}
var showConfirm by remember { mutableStateOf(false) }
if (showConfirm) {
AlertDialog(
onDismissRequest = { showConfirm = false },
confirmButton = {
TextButton(onClick = {
val id = System.currentTimeMillis()
val v = VeranstaltungV2(
id = id,
veranstalterId = selectedVereinId,
titel = titel.trim(),
datumVon = von.trim(),
datumBis = bis.trim().ifBlank { null },
untertitel = untertitel.trim(),
ort = ort.trim().ifBlank { StoreV2.vereine.find { it.id == selectedVereinId }?.ort ?: "" },
logoUrl = logoUrl.trim().ifBlank { null }
)
sponsorenText.split(",").filter { it.isNotBlank() }.forEach { v.sponsoren.add(it.trim()) }
StoreV2.addEventFirst(selectedVereinId, v)
showConfirm = false
onSaved(id, selectedVereinId)
}) { Text("Anlegen") }
},
dismissButton = { TextButton(onClick = { showConfirm = false }) { Text("Abbrechen") } },
title = { Text("Veranstaltung final anlegen?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Bitte die Daten prüfen. Für diese Veranstaltung wird eine eigene Datenbank initialisiert.")
HorizontalDivider()
val titelText = titel.trim()
val untertitelText = untertitel.trim().ifBlank { "-" }
val vonText = von.trim()
val bisText = bis.trim()
val zeitraumText = if (bisText.isNotEmpty()) "$vonText $bisText" else vonText
val vName = StoreV2.vereine.find { it.id == selectedVereinId }?.name ?: "#$selectedVereinId"
val spons = sponsorenText.split(',').map { it.trim() }.filter { it.isNotEmpty() }
Text("Titel: $titelText")
Text("Untertitel: $untertitelText")
Text("Zeitraum: $zeitraumText")
Text("Veranstalter: $vName")
if (logoUrl.isNotBlank()) Text("Logo: ${logoUrl.trim()}")
if (spons.isNotEmpty()) Text("Sponsoren: ${spons.joinToString(", ")}")
}
}
)
}
Button(
onClick = {
if (currentStep < 3) {
currentStep++
} else {
val id = System.currentTimeMillis()
val v = VeranstaltungV2(
id = id,
veranstalterId = selectedVereinId,
titel = titel.trim(),
datumVon = von.trim(),
datumBis = bis.trim().ifBlank { null },
untertitel = untertitel.trim(),
ort = ort.trim().ifBlank { StoreV2.vereine.find { it.id == selectedVereinId }?.ort ?: "" },
logoUrl = logoUrl.trim().ifBlank { null }
)
sponsorenText.split(",").filter { it.isNotBlank() }.forEach {
v.sponsoren.add(it.trim())
}
StoreV2.addEventFirst(selectedVereinId, v)
onSaved(id, selectedVereinId)
showConfirm = true
}
},
enabled = when (currentStep) {