Implement Veranstalter and Veranstaltung management: Add VeranstalterDetailScreen, seed FakeVeranstaltungStore, and enable deletion of Veranstaltungen. Extend onboarding with device name validation. Refine UI for VeranstalterKonfigScreen, add InvalidContextNotice, and centralize navigation checks.

This commit is contained in:
2026-03-28 01:37:27 +01:00
parent b7e78bbab5
commit 7a10d8bb18
17 changed files with 522 additions and 282 deletions
@@ -36,10 +36,11 @@ fun DesktopApp() {
val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login) {
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding) {
LaunchedEffect(Unit) {
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
// Wenn noch keine Authentifizierung vorhanden ist, zuerst Onboarding anzeigen
nav.navigateToScreen(AppScreen.Onboarding)
}
}
@@ -51,7 +51,7 @@ fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1400.dp, height = 900.dp),
state = WindowState(width = 1600.dp, height = 900.dp),
) {
DesktopApp()
}
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
* Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet.
*/
class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Login())
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Onboarding)
override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override fun navigateTo(route: String) {
@@ -3,6 +3,8 @@ package at.mocode.desktop.screens.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout
@@ -24,6 +26,8 @@ import at.mocode.turnier.feature.presentation.TurnierNeuScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
@@ -264,6 +268,19 @@ private fun BreadcrumbSeparator() {
)
}
@Composable
private fun InvalidContextNotice(message: String, onBack: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, color = Color(0xFFB91C1C), fontSize = 14.sp)
Spacer(Modifier.height(12.dp))
Button(onClick = onBack) { Text("Zur Auswahl") }
}
}
/**
* Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen.
*/
@@ -273,6 +290,18 @@ private fun DesktopContentArea(
onNavigate: (AppScreen) -> Unit,
) {
when (currentScreen) {
// Onboarding ohne Login
is AppScreen.Onboarding -> {
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
at.mocode.desktop.screens.onboarding.OnboardingScreen(
onContinue = { _, _, _ ->
// Dummy-Token setzen, damit nach Onboarding der Admin-Flow sichtbar ist
authTokenManager.setToken("dummy.jwt.token")
onNavigate(AppScreen.Veranstaltungen)
}
)
}
// Root-Screen: Admin-Übersicht
is AppScreen.Veranstaltungen -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
@@ -291,26 +320,85 @@ private fun DesktopContentArea(
onAbbrechen = { onNavigate(AppScreen.VeranstalterAuswahl) },
onSpeichern = { _, _, _ -> onNavigate(AppScreen.VeranstalterAuswahl) },
)
is AppScreen.VeranstalterDetail -> VeranstalterDetailScreen(
veranstalterId = currentScreen.veranstalterId,
onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { vId ->
onNavigate(AppScreen.VeranstaltungUebersicht(currentScreen.veranstalterId, vId))
},
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) },
)
is AppScreen.VeranstaltungUebersicht -> VeranstaltungUebersichtScreen(
veranstalterId = currentScreen.veranstalterId,
veranstaltungId = currentScreen.veranstaltungId,
onZurueck = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) },
onTurnierOeffnen = { tId ->
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, tId))
},
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.veranstaltungId)) },
onZnsImport = { /* TODO: ZNS-Import Dialog für Turnier */ },
onDbImport = { /* TODO: DB-Import Dialog */ },
onDbExport = { /* TODO: DB-Export Dialog */ },
)
is AppScreen.VeranstalterDetail -> {
val vId = currentScreen.veranstalterId
if (!FakeVeranstalterStore.exists(vId)) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
)
} else {
VeranstalterDetailScreen(
veranstalterId = vId,
onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { evtId ->
onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId))
},
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) },
)
}
}
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
if (!FakeVeranstalterStore.exists(vId)) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
)
} else {
at.mocode.veranstalter.feature.presentation.VeranstaltungKonfigScreen(
veranstalterId = vId,
onAbbrechen = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onSpeichern = { titel, datumVon, datumBis ->
// ID generieren und in den Fake-Store einfügen
val id = System.currentTimeMillis()
at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore.addFirst(
vId,
at.mocode.veranstalter.feature.presentation.VeranstaltungListUiModel(
id = id,
name = titel,
datum = if (datumBis.isNotBlank()) "$datumVon $datumBis" else datumVon,
ort = "",
turnierAnzahl = 1,
nennungen = 0,
bewerbe = 0,
letzteAktivitaet = "soeben",
status = at.mocode.frontend.core.designsystem.models.VeranstaltungStatus.VORBEREITUNG,
)
)
onNavigate(AppScreen.VeranstalterDetail(vId))
}
)
}
}
is AppScreen.VeranstaltungUebersicht -> {
val vId = currentScreen.veranstalterId
val evtId = currentScreen.veranstaltungId
if (!FakeVeranstalterStore.exists(vId)) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
)
} else if (!FakeVeranstaltungStore.belongsTo(vId, evtId)) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.",
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }
)
} else {
VeranstaltungUebersichtScreen(
veranstalterId = vId,
veranstaltungId = evtId,
onZurueck = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onTurnierOeffnen = { tId ->
onNavigate(AppScreen.TurnierDetail(evtId, tId))
},
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(evtId)) },
onZnsImport = { /* TODO */ },
onDbImport = { /* TODO */ },
onDbExport = { /* TODO */ },
)
}
}
// Veranstaltungs-Screens
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
@@ -325,16 +413,36 @@ private fun DesktopContentArea(
)
// Turnier-Screens
is AppScreen.TurnierDetail -> TurnierDetailScreen(
veranstaltungId = currentScreen.veranstaltungId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
is AppScreen.TurnierNeu -> TurnierNeuScreen(
veranstaltungId = currentScreen.veranstaltungId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
is AppScreen.TurnierDetail -> {
val evtId = currentScreen.veranstaltungId
if (!FakeVeranstaltungStore.exists(evtId)) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) }
)
} else {
TurnierDetailScreen(
veranstaltungId = evtId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) },
)
}
}
is AppScreen.TurnierNeu -> {
val evtId = currentScreen.veranstaltungId
if (!FakeVeranstaltungStore.exists(evtId)) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) }
)
} else {
TurnierNeuScreen(
veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) },
onSave = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) },
)
}
}
// Ping-Screen
is AppScreen.Ping -> {
@@ -0,0 +1,92 @@
package at.mocode.desktop.screens.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
enum class ZnsStatus { NONE, LOCAL, SYNCED }
@Composable
fun OnboardingScreen(
initialName: String = "",
initialKey: String = "",
initialZns: ZnsStatus = ZnsStatus.NONE,
onZnsSync: () -> Unit = {},
onZnsUsb: () -> Unit = {},
onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit,
) {
var geraetName by remember { mutableStateOf(initialName) }
var sharedKey by remember { mutableStateOf(initialKey) }
var znsStatus by remember { mutableStateOf(initialZns) }
var showPassword by remember { mutableStateOf(false) }
val nameValid = geraetName.trim().length >= 3
val keyValid = sharedKey.trim().length >= 8
val canContinue = nameValid && keyValid
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall)
Card {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
placeholder = { Text("z. B. Meldestelle") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !nameValid && geraetName.isNotBlank()
)
Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = sharedKey,
onValueChange = { sharedKey = it },
placeholder = { Text("z. B. Neumarkt2026") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !keyValid && sharedKey.isNotBlank(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val label = if (showPassword) "Verbergen" else "Anzeigen"
TextButton(onClick = { showPassword = !showPassword }) {
Text(label)
}
}
)
Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = {
znsStatus = ZnsStatus.SYNCED
onZnsSync()
}, label = { Text("Aktualisieren") })
AssistChip(onClick = {
znsStatus = ZnsStatus.LOCAL
onZnsUsb()
}, label = { Text("USB-Import") })
Spacer(Modifier.width(8.dp))
Text("Status: $znsStatus")
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) {
Text("Weiter zu den Veranstaltungen")
}
if (!canContinue) {
Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error)
}
}
}
}