refactor(ui, navigation): implement platform-specific routing and redesign components

- Added platform detection logic `currentPlatform()` in `PlatformType.js.kt`.
- Introduced platform-based behavior for LandingScreen, Dashboard, and Login flow.
- Replaced Row with FlowRow in PingScreen to improve button layout.
- Updated Meldestelle Dashboard with platform-specific headers and authentication checks.
- Adjusted AppHeader to accept `isAuthenticated` and `username` parameters.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-19 16:21:23 +01:00
parent 931fe7badb
commit 62264d9e02
15 changed files with 612 additions and 206 deletions

View File

@ -61,6 +61,12 @@
"composite": false,
"clientRole": false
},
{
"name": "ORGANIZER",
"description": "Veranstalter role for managing tournaments",
"composite": false,
"clientRole": false
},
{
"name": "MONITORING",
"description": "Monitoring role for system health checks",
@ -301,6 +307,26 @@
"ADMIN"
]
}
},
{
"username": "urfv_neumarkt",
"enabled": true,
"emailVerified": true,
"firstName": "URFV",
"lastName": "Neumarkt",
"email": "office@urfv-neumarkt.at",
"credentials": [
{
"type": "password",
"value": "Turnier#2026",
"temporary": false
}
],
"realmRoles": [
"USER",
"ORGANIZER"
],
"clientRoles": {}
}
],
"groups": [],

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.*
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.ExperimentalTime
@ -49,7 +49,8 @@ data class JwtPayload(
val exp: Long? = null, // Expiration timestamp
val iat: Long? = null, // Issued at timestamp
val iss: String? = null, // Issuer
val permissions: List<String>? = null // Permissions array
val permissions: List<String>? = null, // Permissions array
val roles: List<String>? = null // Realm Roles from Keycloak
)
/**
@ -60,7 +61,8 @@ data class AuthState(
val token: String? = null,
val userId: String? = null,
val username: String? = null,
val permissions: List<Permission> = emptyList()
val permissions: List<Permission> = emptyList(),
val roles: List<String> = emptyList() // Added roles
)
/**
@ -73,6 +75,9 @@ data class AuthState(
@Suppress("unused")
class AuthTokenManager {
// Shared Json instance to avoid redundant creation
private val jsonParser = Json { ignoreUnknownKeys = true }
private var currentToken: String? = null
private var tokenPayload: JwtPayload? = null
@ -96,12 +101,15 @@ class AuthTokenManager {
}
} ?: emptyList()
val roles = tokenPayload?.roles ?: emptyList()
_authState.value = AuthState(
isAuthenticated = true,
token = token,
userId = tokenPayload?.sub,
username = tokenPayload?.username,
permissions = permissions
permissions = permissions,
roles = roles
)
}
@ -150,6 +158,11 @@ class AuthTokenManager {
*/
fun getPermissions(): List<Permission> = _authState.value.permissions
/**
* Get current user roles (Keycloak Realm Roles)
*/
fun getRoles(): List<String> = _authState.value.roles
/**
* Check if the user has a specific permission
*/
@ -157,6 +170,13 @@ class AuthTokenManager {
return _authState.value.permissions.contains(permission)
}
/**
* Check if the user has a specific role
*/
fun hasRole(role: String): Boolean {
return _authState.value.roles.contains(role)
}
/**
* Check if the user has any of the specified permissions
*/
@ -222,7 +242,9 @@ class AuthTokenManager {
/**
* Check if the user is admin (has deleted permissions)
*/
fun isAdmin(): Boolean = canDelete()
fun isAdmin(): Boolean = hasRole("ADMIN")
fun isOrganizer(): Boolean = hasRole("ORGANIZER")
/**
* Check if the token expires within specified minutes
@ -252,28 +274,48 @@ class AuthTokenManager {
// First, try to parse with a standard approach
val basicPayload = try {
Json.decodeFromString<JwtPayload>(payloadJson)
jsonParser.decodeFromString<JwtPayload>(payloadJson)
} catch (e: Exception) {
// If that fails, extract manually
// If that fails, try to extract it manually
null
}
// If basic parsing succeeded and has permissions, return it
if (basicPayload != null && basicPayload.permissions != null) {
return basicPayload
// Try to parse JSON to extract roles which are often nested in realm_access.roles
var roles: List<String>? = basicPayload?.roles
var username: String? = basicPayload?.username
try {
val jsonObject = jsonParser.decodeFromString<JsonObject>(payloadJson)
if (roles == null) {
// Try Keycloak specific format: "realm_access": { "roles": ["ADMIN", "USER"] }
val realmAccess = jsonObject["realm_access"]?.jsonObject
val rolesArray = realmAccess?.get("roles")?.jsonArray
roles = rolesArray?.map { it.jsonPrimitive.content }
}
if (username == null) {
// try preferred_username
username = jsonObject["preferred_username"]?.jsonPrimitive?.content
}
} catch (e: Exception) {
// Ignore
}
// Otherwise, extract permissions manually from a JSON string
val permissions = extractPermissionsFromJson(payloadJson)
// Return payload with manually extracted permissions
// Extract permissions manually from a JSON string if needed
val permissions = basicPayload?.permissions ?: extractPermissionsFromJson(payloadJson)
// Return payload
JwtPayload(
sub = basicPayload?.sub,
username = basicPayload?.username,
username = username,
exp = basicPayload?.exp,
iat = basicPayload?.iat,
iss = basicPayload?.iss,
permissions = permissions
permissions = permissions,
roles = roles
)
} catch (e: Exception) {
// Failed to parse - token might be invalid format

View File

@ -11,8 +11,8 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -28,7 +28,7 @@ fun LoginScreen(
onBack: () -> Unit = {} // New callback for back navigation
) {
val uiState by viewModel.uiState.collectAsState()
val passwordFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var passwordVisible by remember { mutableStateOf(false) }
Scaffold(
@ -64,8 +64,9 @@ fun LoginScreen(
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
@ -98,14 +99,15 @@ fun LoginScreen(
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (uiState.canLogin) {
viewModel.login()
}
}
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.padding(bottom = 24.dp)
)
@ -131,7 +133,10 @@ fun LoginScreen(
// Login button
Button(
onClick = { viewModel.login() },
onClick = {
focusManager.clearFocus()
viewModel.login()
},
enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier
.fillMaxWidth()

View File

@ -1,74 +1,67 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppHeader(
title: String,
onNavigateToPing: (() -> Unit)? = null,
isAuthenticated: Boolean,
username: String?,
onNavigateToLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null,
isAuthenticated: Boolean = false,
username: String? = null,
userPermissions: List<String> = emptyList()
onLogout: (() -> Unit)? = null
) {
TopAppBar(
title = {
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
text = "Meldestelle",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
},
actions = {
// Ping Service button
onNavigateToPing?.let { navigateAction ->
TextButton(
onClick = navigateAction
) {
Text("Ping Service")
}
}
// Authentication buttons
if (isAuthenticated) {
// Show username with admin indicator if user has deleted permissions
username?.let { user ->
val isAdmin = userPermissions.any { it.contains("DELETE") }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (isAuthenticated) {
// Show the username and logout button
Text(
text = if (isAdmin) "👑 Hallo, $user (Admin)" else "Hallo, $user",
text = "Angemeldet als: ${username ?: "Admin"}",
style = MaterialTheme.typography.bodyMedium,
color = if (isAdmin)
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.onPrimaryContainer
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
onLogout?.let { logoutAction ->
TextButton(
onClick = logoutAction
) {
Text("Abmelden")
onLogout?.let { logoutAction ->
TextButton(onClick = logoutAction) {
Text("Abmelden")
}
}
}
} else {
// Show the login button
onNavigateToLogin?.let { loginAction ->
TextButton(
onClick = loginAction
) {
Text("Anmelden")
} else {
// Show the login button
onNavigateToLogin?.let { loginAction ->
Button(
onClick = loginAction
) {
Text("Login")
}
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
}
}

View File

@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier
@Composable
fun AppScaffold(
header: @Composable () -> Unit = {
AppHeader(title = "Meldestelle")
AppHeader(isAuthenticated = false, username = null)
},
content: @Composable (PaddingValues) -> Unit,
footer: @Composable () -> Unit = {

View File

@ -0,0 +1,8 @@
package at.mocode.frontend.core.domain
enum class PlatformType {
WEB,
DESKTOP
}
expect fun currentPlatform(): PlatformType

View File

@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.WEB

View File

@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.DESKTOP

View File

@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.WEB

View File

@ -5,7 +5,10 @@ sealed class AppScreen(val route: String) {
data object Home : AppScreen("/home")
data object Dashboard : AppScreen("/dashboard")
data object CreateTournament : AppScreen("/tournament/create") // Neuer Screen
data object Login : AppScreen(Routes.LOGIN)
// Login now accepts an optional returnTo screen to determine where to go after success
data class Login(val returnTo: AppScreen? = null) : AppScreen(Routes.LOGIN)
data object Ping : AppScreen("/ping")
data object Profile : AppScreen("/profile")
data object AuthCallback : AppScreen("/auth/callback")
@ -17,7 +20,7 @@ sealed class AppScreen(val route: String) {
"/home" -> Home
"/dashboard" -> Dashboard
"/tournament/create" -> CreateTournament
Routes.LOGIN, Routes.Auth.LOGIN -> Login
Routes.LOGIN, Routes.Auth.LOGIN -> Login()
"/ping" -> Ping
"/profile" -> Profile
"/auth/callback" -> AuthCallback

View File

@ -28,6 +28,7 @@ kotlin {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth) // Added auth module for AuthTokenManager
implementation(libs.sqldelight.coroutines)
implementation(projects.frontend.core.domain)
@ -43,6 +44,7 @@ kotlin {
implementation(libs.bundles.compose.common)
implementation(libs.koin.core)
implementation(libs.koin.compose) // Added koin.compose for koinInject
}
commonTest.dependencies {

View File

@ -133,9 +133,11 @@ private fun StatusBadge(text: String, color: Color) {
@Composable
private fun ActionToolbar(viewModel: PingViewModel) {
// Wrap buttons to avoid overflow on small screens
Row(
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
DenseButton(text = "Simple", onClick = { viewModel.performSimplePing() })
DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() })

View File

@ -5,13 +5,17 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.components.AppFooter
import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.domain.PlatformType
import at.mocode.frontend.core.domain.currentPlatform
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel
@ -38,27 +42,58 @@ fun MainApp() {
val pingViewModel: PingViewModel = koinViewModel()
val loginViewModel: LoginViewModel = koinViewModel()
when (currentScreen) {
is AppScreen.Landing -> LandingScreen(
onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login) }
)
when (val screen = currentScreen) {
is AppScreen.Landing -> {
if (currentPlatform() == PlatformType.DESKTOP) {
// Master Desktop: MUSS eingeloggt sein, um irgendwas zu sehen.
// Wir leiten für den POC erst mal hart auf den Login, wenn nicht eingeloggt
val authState = authTokenManager.authState.collectAsState().value
if (authState.isAuthenticated) {
DashboardScreen(
authTokenManager = authTokenManager,
onLogout = {
authTokenManager.clearToken()
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
},
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
)
} else {
LaunchedEffect(Unit) {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
}
}
} else {
LandingScreen(
onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) }, // Takes you to Meldestelle login
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Profile) } // Open the Ping Overview / Status page
)
}
}
is AppScreen.Dashboard -> DashboardScreen(
authTokenManager = authTokenManager,
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
onLogout = {
authTokenManager.clearToken()
navigationPort.navigateToScreen(AppScreen.Landing)
}
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
},
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
)
is AppScreen.Home -> DashboardScreen( // Route /home to Dashboard for now
authTokenManager = authTokenManager,
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
onLogout = {
authTokenManager.clearToken()
navigationPort.navigateToScreen(AppScreen.Landing)
}
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
},
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
)
is AppScreen.CreateTournament -> CreateTournamentScreen(
@ -66,20 +101,47 @@ fun MainApp() {
onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) } // Later we go to tournament detail
)
is AppScreen.Login -> LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = { navigationPort.navigateToScreen(AppScreen.Dashboard) },
onBack = { navigationPort.navigateToScreen(AppScreen.Landing) }
)
is AppScreen.Login -> {
// wir prüfen hier, woher wir kamen (falls wir das im State hätten)
// Im Moment: nach erfolgreichem Login zum returnTo-Screen navigieren, Default ist Dashboard
LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
val targetScreen = screen.returnTo ?: AppScreen.Dashboard
navigationPort.navigateToScreen(targetScreen)
},
onBack = {
if (currentPlatform() == PlatformType.DESKTOP) {
// No real back from login in desktop if forced, but let's go to Dashboard empty state
// This is a bit tricky, but for PoC we just clear
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
}
)
}
is AppScreen.Ping -> PingScreen(
viewModel = pingViewModel,
onBack = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Always go back to overview
)
is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager,
onBackToHome = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
onNavigateToLogin = {
// Hier nutzen wir den dynamischen Return-Pfad: Nach dem Login soll der User
// exakt auf dieses AuthStatusScreen/Profile zurückkehren.
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Profile))
},
onNavigateToPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
onBackToHome = {
if (currentPlatform() == PlatformType.DESKTOP) {
navigationPort.navigateToScreen(AppScreen.Dashboard)
} else {
navigationPort.navigateToScreen(AppScreen.Landing)
}
}
)
else -> {}
@ -90,7 +152,8 @@ fun MainApp() {
@Composable
private fun LandingScreen(
onPrimaryCta: () -> Unit
onPrimaryCta: () -> Unit,
onOpenPing: () -> Unit
) {
val scrollState = rememberScrollState()
@ -142,41 +205,6 @@ private fun LandingScreen(
)
}
// --- AKTUELLE TURNIERE SECTION ---
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 40.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text("Aktuelle Turniere", style = MaterialTheme.typography.headlineMedium)
// Dummy Daten basierend auf Neumarkt 2026
val turniere = listOf(
TournamentData(
id = "26128",
date = "25. APRIL 2026",
title = "CSN-C NEU CSNP-C NEU",
location = "NEUMARKT/M., OÖ"
),
TournamentData(
id = "26129",
date = "26. APRIL 2026",
title = "CDN-C NEU CDNP-C NEU",
location = "NEUMARKT/M., OÖ"
)
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
turniere.forEach { turnier ->
TournamentCard(turnier)
}
}
}
// Manifest / Intro
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Column(
@ -233,6 +261,18 @@ private fun LandingScreen(
}
}
// Ping Service Link
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(onClick = onOpenPing) {
Text("System Status (Ping-Service)")
}
}
// Footer
AppFooter()
}
@ -355,10 +395,24 @@ private fun FeatureCard(number: String, title: String, body: String) {
@Composable
private fun DashboardScreen(
authTokenManager: AuthTokenManager,
onOpenPing: () -> Unit,
onLogout: () -> Unit
onLogout: () -> Unit,
onCreateTournament: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
val scrollState = rememberScrollState()
val isDesktop = currentPlatform() == PlatformType.DESKTOP
// Security Check für das Dashboard
if (!authState.isAuthenticated) {
// Wenn nicht eingeloggt, zeige nur eine leere Seite oder einen Hinweis an
// (Die Umleitung zum Login passiert in MainApp bzw. LaunchedEffect)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val isAdmin = authTokenManager.isAdmin()
Column(
modifier = Modifier.fillMaxSize()
@ -375,7 +429,7 @@ private fun DashboardScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Meldestelle Dashboard",
text = if (isDesktop) "Master-Meldestelle Steuerungszentrale" else if (isAdmin) "Admin-Dashboard (Web)" else "Veranstalter-Dashboard",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
@ -396,112 +450,351 @@ private fun DashboardScreen(
}
// Main Content Area
Row(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Left Column (Turniere)
Column(
modifier = Modifier.weight(2f),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Meine Turniere", style = MaterialTheme.typography.headlineSmall)
// Dummy Turniere für die Meldestelle
val turniere = listOf(
TournamentData(
id = "26128",
date = "25. APRIL 2026",
title = "CSN-C NEU CSNP-C NEU",
location = "NEUMARKT/M., OÖ"
),
TournamentData(
id = "26129",
date = "26. APRIL 2026",
title = "CDN-C NEU CDNP-C NEU",
location = "NEUMARKT/M., OÖ"
if (isDesktop && isAdmin) {
// DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN (DICH)
// Neues Turnier anlegen Button
OutlinedButton(
onClick = onCreateTournament,
modifier = Modifier.fillMaxWidth().height(64.dp)
) {
Text(
text = "+ neues Turnier anlegen",
style = MaterialTheme.typography.titleMedium
)
}
// Meine Turniere Section
Text(
text = "Alle verwalteten Turniere",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
turniere.forEach { turnier ->
// Filters (Mockup)
Surface(
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Zeitraum:", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = "März",
onValueChange = {},
modifier = Modifier.width(100.dp).height(48.dp),
singleLine = true
)
Text("bis", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = "Dezember",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "2026",
onValueChange = {},
modifier = Modifier.width(80.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Bundesland",
onValueChange = {},
modifier = Modifier.width(150.dp).height(48.dp),
singleLine = true
)
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67E22))
) { // Orange color
Text("Anzeigen")
}
}
Text("Zusätzliche Filter auf Suchergebnisse:", style = MaterialTheme.typography.bodyMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = "Veranstalter (Verein)",
onValueChange = {},
modifier = Modifier.width(180.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Ort",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Sparte",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
OutlinedTextField(
value = "Turnierart",
onValueChange = {},
modifier = Modifier.width(120.dp).height(48.dp),
singleLine = true
)
}
}
}
// Desktop Tournament Card (Steuerungszentrale Ansicht)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
border = androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Left Side: Logo and Text
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.weight(1f)
) {
// Logo Placeholder
Surface(
modifier = Modifier.size(120.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.small
) {
Box(contentAlignment = Alignment.Center) {
Text("NEUMARKT\nLOGO", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall)
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("CDN-C NEU CDNP-C", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Veranstalter: URFV Neumarkt",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text("NEUMARKT/M., OÖ 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium)
Text("Turnier-Nr.: 26129", style = MaterialTheme.typography.bodyMedium)
}
}
// Right Side: Toggles (Statusanzeigen für den Admin)
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
ToggleRow("Meldestelle-Desktop online", isOnline = true, isInteractive = false)
ToggleRow("Nennsystem online", isOnline = true, isInteractive = true)
ToggleRow("Start- Ergebnislisten online", isOnline = true, isInteractive = true)
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { /* Link kopieren oder Email Dialog öffnen */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Veranstalter-Link senden")
}
}
}
}
} else if (isDesktop && !isAdmin) {
// DESKTOP VIEW - VERANSTALTER (Meldestelle am Platz)
Text(
"Willkommen in der Meldestellen-Software für Turnier 26129",
style = MaterialTheme.typography.headlineMedium
)
Text("Bitte initialisieren Sie die lokale Datenbank oder importieren Sie einen Stand vom USB-Stick.")
Spacer(Modifier.height(16.dp))
Button(onClick = onCreateTournament) { // This acts as the "Setup Wizard"
Text("Turnier initialisieren / Importieren")
}
} else if (!isDesktop && isAdmin) {
// WEB VIEW - ADMIN PORTAL (Deine Steuerungszentrale im Web)
Text(
text = "Alle verwalteten Turniere",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium)
Text(
"CDN-C NEU CDNP-C Neumarkt",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text("Veranstalter: URFV Neumarkt", style = MaterialTheme.typography.bodyMedium)
Text("Nr: 26129 | 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium)
}
Button(onClick = { /* TODO: Open Meldestellen Cockpit for this tournament */ }) {
Text("Meldestelle öffnen")
Button(onClick = { /* TODO: Download Trigger for Master App */ }) {
Text("Master-Desktop-App herunterladen")
}
}
}
}
// DEIN NEUES KONZEPT: Download Desktop App statt "Neues Turnier anlegen" im Web
} else {
// WEB VIEW - VERANSTALTER PORTAL
// Top: Aktuelles Turnier & Download
Card(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Master-Meldestelle Desktop App",
"Aktuelles Turnier: CDN-C NEU CDNP-C Neumarkt",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
"Für die Turnieranlage, den ZNS-Import und vollen Offline-Betrieb (USB-Sync) laden Sie bitte die Desktop-Anwendung herunter.",
"Turnier-Nr.: 26129 | 26. APRIL 2026",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(Modifier.height(8.dp))
Text(
"Bitte laden Sie die Desktop-Anwendung herunter, um die Meldestelle lokal an Ihrem Turnierplatz zu betreiben.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 8.dp)
) {
Button(onClick = { /* TODO: Download Trigger */ }) {
Text("Download für Windows (.exe)")
Button(onClick = { /* TODO: Download Trigger for generic app */ }) {
Text("Meldestelle-App herunterladen (.exe)")
}
// Status Anzeige
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
// Lizenz-Key Anzeige
Surface(
modifier = Modifier.size(12.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.error
) {}
Text("Desktop App derzeit Offline", style = MaterialTheme.typography.labelMedium)
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
) {
Text(
"Ihr Aktivierungs-Code: X7F9-K2M4",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
// Middle: Historie (Meine Turniere)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Text("Turnier-Historie (Archiv)", style = MaterialTheme.typography.headlineSmall)
// Dummy Turniere aus der Vergangenheit
val turniere = listOf(
TournamentData(
id = "25044",
date = "24. APRIL 2025",
title = "CSN-C CSNP-C Neumarkt",
location = "NEUMARKT/M., OÖ"
),
TournamentData(
id = "24012",
date = "28. APRIL 2024",
title = "CDN-C CDNP-C Neumarkt",
location = "NEUMARKT/M., OÖ"
)
)
turniere.forEach { turnier ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { /* TODO: Open PDF Archive */ }) {
Text("Ergebnislisten (PDF)")
}
OutlinedButton(onClick = { /* TODO: Open Stats */ }) {
Text("Statistiken")
}
}
}
}
}
}
}
}
}
}
// Right Column (System / Tools)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("System & Tools", style = MaterialTheme.typography.headlineSmall)
@Composable
private fun ToggleRow(label: String, isOnline: Boolean, isInteractive: Boolean = false) {
Surface(
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth().height(40.dp),
color = if (isInteractive) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Fake status circle
val statusColor = if (isOnline) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
Surface(
modifier = Modifier.size(16.dp),
shape = androidx.compose.foundation.shape.CircleShape,
color = statusColor
) {}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = onOpenPing,
modifier = Modifier.fillMaxWidth()
) {
Text("Ping-Service (System Status)")
// Fake switch / Status text
if (isInteractive) {
Surface(
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.width(40.dp).height(24.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(if (isOnline) "on" else "off", style = MaterialTheme.typography.labelSmall)
}
}
} else {
Text(
if (isOnline) "Online" else "Offline",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(40.dp),
textAlign = TextAlign.Center
)
}
}
}
}
}
}
@ -898,6 +1191,8 @@ fun TournamentStepBewerbe() {
@Composable
private fun AuthStatusScreen(
authTokenManager: AuthTokenManager,
onNavigateToLogin: () -> Unit,
onNavigateToPing: () -> Unit,
onBackToHome: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
@ -907,24 +1202,45 @@ private fun AuthStatusScreen(
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Profil / Status", style = MaterialTheme.typography.headlineMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
if (authState.isAuthenticated) {
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
Spacer(Modifier.height(8.dp))
Button(onClick = {
authTokenManager.clearToken()
onBackToHome()
}) { Text("Abmelden") }
Text("Ping-Service / System Status", style = MaterialTheme.typography.headlineMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (authState.isAuthenticated) {
Text(
"Du bist angemeldet als: ${authState.username ?: authState.userId ?: "unbekannt"}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = onBackToHome) { Text("Zurück zum Dashboard") }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNavigateToPing) {
Text("Ping-Service Tests durchführen")
}
OutlinedButton(onClick = {
authTokenManager.clearToken()
}) { Text("Abmelden") }
}
} else {
Text("Nicht angemeldet.")
Text(
"Du bist abgemeldet.",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
Button(onClick = onBackToHome) { Text("Zurück") }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onNavigateToLogin) {
Text("Login")
}
OutlinedButton(onClick = onNavigateToPing) {
Text("Ping-Service (eingeschränkt testen)")
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
OutlinedButton(onClick = onBackToHome) { Text("Zurück zur Startseite") }
}
}
}

View File

@ -40,7 +40,7 @@ fun main() = application {
}
Window(
onCloseRequest = ::exitApplication,
title = "Equest-Events Master Desktop",
title = "Master Desktop",
state = WindowState(width = 1200.dp, height = 800.dp)
) {
MainApp()