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:
2026-03-19 16:21:23 +01:00
parent 931fe7badb
commit 62264d9e02
15 changed files with 612 additions and 206 deletions
@@ -61,6 +61,12 @@
"composite": false, "composite": false,
"clientRole": false "clientRole": false
}, },
{
"name": "ORGANIZER",
"description": "Veranstalter role for managing tournaments",
"composite": false,
"clientRole": false
},
{ {
"name": "MONITORING", "name": "MONITORING",
"description": "Monitoring role for system health checks", "description": "Monitoring role for system health checks",
@@ -301,6 +307,26 @@
"ADMIN" "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": [], "groups": [],
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.*
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -49,7 +49,8 @@ data class JwtPayload(
val exp: Long? = null, // Expiration timestamp val exp: Long? = null, // Expiration timestamp
val iat: Long? = null, // Issued at timestamp val iat: Long? = null, // Issued at timestamp
val iss: String? = null, // Issuer 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 token: String? = null,
val userId: String? = null, val userId: String? = null,
val username: 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") @Suppress("unused")
class AuthTokenManager { class AuthTokenManager {
// Shared Json instance to avoid redundant creation
private val jsonParser = Json { ignoreUnknownKeys = true }
private var currentToken: String? = null private var currentToken: String? = null
private var tokenPayload: JwtPayload? = null private var tokenPayload: JwtPayload? = null
@@ -96,12 +101,15 @@ class AuthTokenManager {
} }
} ?: emptyList() } ?: emptyList()
val roles = tokenPayload?.roles ?: emptyList()
_authState.value = AuthState( _authState.value = AuthState(
isAuthenticated = true, isAuthenticated = true,
token = token, token = token,
userId = tokenPayload?.sub, userId = tokenPayload?.sub,
username = tokenPayload?.username, username = tokenPayload?.username,
permissions = permissions permissions = permissions,
roles = roles
) )
} }
@@ -150,6 +158,11 @@ class AuthTokenManager {
*/ */
fun getPermissions(): List<Permission> = _authState.value.permissions 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 * Check if the user has a specific permission
*/ */
@@ -157,6 +170,13 @@ class AuthTokenManager {
return _authState.value.permissions.contains(permission) 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 * 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) * 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 * Check if the token expires within specified minutes
@@ -252,28 +274,48 @@ class AuthTokenManager {
// First, try to parse with a standard approach // First, try to parse with a standard approach
val basicPayload = try { val basicPayload = try {
Json.decodeFromString<JwtPayload>(payloadJson) jsonParser.decodeFromString<JwtPayload>(payloadJson)
} catch (e: Exception) { } catch (e: Exception) {
// If that fails, extract manually // If that fails, try to extract it manually
null null
} }
// If basic parsing succeeded and has permissions, return it // Try to parse JSON to extract roles which are often nested in realm_access.roles
if (basicPayload != null && basicPayload.permissions != null) { var roles: List<String>? = basicPayload?.roles
return basicPayload 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( JwtPayload(
sub = basicPayload?.sub, sub = basicPayload?.sub,
username = basicPayload?.username, username = username,
exp = basicPayload?.exp, exp = basicPayload?.exp,
iat = basicPayload?.iat, iat = basicPayload?.iat,
iss = basicPayload?.iss, iss = basicPayload?.iss,
permissions = permissions permissions = permissions,
roles = roles
) )
} catch (e: Exception) { } catch (e: Exception) {
// Failed to parse - token might be invalid format // Failed to parse - token might be invalid format
@@ -11,8 +11,8 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -28,7 +28,7 @@ fun LoginScreen(
onBack: () -> Unit = {} // New callback for back navigation onBack: () -> Unit = {} // New callback for back navigation
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val passwordFocusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@@ -64,8 +64,9 @@ fun LoginScreen(
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() } onNext = { focusManager.moveFocus(FocusDirection.Next) }
), ),
singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
@@ -98,14 +99,15 @@ fun LoginScreen(
), ),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onDone = { onDone = {
focusManager.clearFocus()
if (uiState.canLogin) { if (uiState.canLogin) {
viewModel.login() viewModel.login()
} }
} }
), ),
singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(passwordFocusRequester)
.padding(bottom = 24.dp) .padding(bottom = 24.dp)
) )
@@ -131,7 +133,10 @@ fun LoginScreen(
// Login button // Login button
Button( Button(
onClick = { viewModel.login() }, onClick = {
focusManager.clearFocus()
viewModel.login()
},
enabled = uiState.canLogin && !uiState.isLoading, enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1,74 +1,67 @@
package at.mocode.frontend.core.designsystem.components 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.material3.*
import androidx.compose.runtime.Composable 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.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppHeader( fun AppHeader(
title: String, isAuthenticated: Boolean,
onNavigateToPing: (() -> Unit)? = null, username: String?,
onNavigateToLogin: (() -> Unit)? = null, onNavigateToLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null
isAuthenticated: Boolean = false,
username: String? = null,
userPermissions: List<String> = emptyList()
) { ) {
TopAppBar( Surface(
title = { 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(
text = title, text = "Meldestelle",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
},
actions = {
// Ping Service button
onNavigateToPing?.let { navigateAction ->
TextButton(
onClick = navigateAction
) {
Text("Ping Service")
}
}
// Authentication buttons Row(
if (isAuthenticated) { verticalAlignment = Alignment.CenterVertically,
// Show username with admin indicator if user has deleted permissions horizontalArrangement = Arrangement.spacedBy(16.dp)
username?.let { user -> ) {
val isAdmin = userPermissions.any { it.contains("DELETE") } if (isAuthenticated) {
// Show the username and logout button
Text( Text(
text = if (isAdmin) "👑 Hallo, $user (Admin)" else "Hallo, $user", text = "Angemeldet als: ${username ?: "Admin"}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (isAdmin) color = MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.onPrimaryContainer
) )
} onLogout?.let { logoutAction ->
onLogout?.let { logoutAction -> TextButton(onClick = logoutAction) {
TextButton( Text("Abmelden")
onClick = logoutAction }
) {
Text("Abmelden")
} }
} } else {
} else { // Show the login button
// Show the login button onNavigateToLogin?.let { loginAction ->
onNavigateToLogin?.let { loginAction -> Button(
TextButton( onClick = loginAction
onClick = loginAction ) {
) { Text("Login")
Text("Anmelden") }
} }
} }
} }
}, }
colors = TopAppBarDefaults.topAppBarColors( }
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
} }
@@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier
@Composable @Composable
fun AppScaffold( fun AppScaffold(
header: @Composable () -> Unit = { header: @Composable () -> Unit = {
AppHeader(title = "Meldestelle") AppHeader(isAuthenticated = false, username = null)
}, },
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
footer: @Composable () -> Unit = { footer: @Composable () -> Unit = {
@@ -0,0 +1,8 @@
package at.mocode.frontend.core.domain
enum class PlatformType {
WEB,
DESKTOP
}
expect fun currentPlatform(): PlatformType
@@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.WEB
@@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.DESKTOP
@@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.WEB
@@ -5,7 +5,10 @@ sealed class AppScreen(val route: String) {
data object Home : AppScreen("/home") data object Home : AppScreen("/home")
data object Dashboard : AppScreen("/dashboard") data object Dashboard : AppScreen("/dashboard")
data object CreateTournament : AppScreen("/tournament/create") // Neuer Screen 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 Ping : AppScreen("/ping")
data object Profile : AppScreen("/profile") data object Profile : AppScreen("/profile")
data object AuthCallback : AppScreen("/auth/callback") data object AuthCallback : AppScreen("/auth/callback")
@@ -17,7 +20,7 @@ sealed class AppScreen(val route: String) {
"/home" -> Home "/home" -> Home
"/dashboard" -> Dashboard "/dashboard" -> Dashboard
"/tournament/create" -> CreateTournament "/tournament/create" -> CreateTournament
Routes.LOGIN, Routes.Auth.LOGIN -> Login Routes.LOGIN, Routes.Auth.LOGIN -> Login()
"/ping" -> Ping "/ping" -> Ping
"/profile" -> Profile "/profile" -> Profile
"/auth/callback" -> AuthCallback "/auth/callback" -> AuthCallback
@@ -28,6 +28,7 @@ kotlin {
implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.sync) implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb) implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth) // Added auth module for AuthTokenManager
implementation(libs.sqldelight.coroutines) implementation(libs.sqldelight.coroutines)
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
@@ -43,6 +44,7 @@ kotlin {
implementation(libs.bundles.compose.common) implementation(libs.bundles.compose.common)
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose) // Added koin.compose for koinInject
} }
commonTest.dependencies { commonTest.dependencies {
@@ -133,9 +133,11 @@ private fun StatusBadge(text: String, color: Color) {
@Composable @Composable
private fun ActionToolbar(viewModel: PingViewModel) { private fun ActionToolbar(viewModel: PingViewModel) {
// Wrap buttons to avoid overflow on small screens // Wrap buttons to avoid overflow on small screens
Row( @OptIn(ExperimentalLayoutApi::class)
FlowRow(
modifier = Modifier.fillMaxWidth(), 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 = "Simple", onClick = { viewModel.performSimplePing() })
DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() }) DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() })
@@ -5,13 +5,17 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.auth.data.AuthTokenManager import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.components.AppFooter import at.mocode.frontend.core.designsystem.components.AppFooter
import at.mocode.frontend.core.designsystem.theme.AppTheme 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.frontend.core.navigation.AppScreen
import at.mocode.ping.feature.presentation.PingScreen import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel import at.mocode.ping.feature.presentation.PingViewModel
@@ -38,27 +42,58 @@ fun MainApp() {
val pingViewModel: PingViewModel = koinViewModel() val pingViewModel: PingViewModel = koinViewModel()
val loginViewModel: LoginViewModel = koinViewModel() val loginViewModel: LoginViewModel = koinViewModel()
when (currentScreen) { when (val screen = currentScreen) {
is AppScreen.Landing -> LandingScreen( is AppScreen.Landing -> {
onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login) } 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( is AppScreen.Dashboard -> DashboardScreen(
authTokenManager = authTokenManager, authTokenManager = authTokenManager,
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
onLogout = { onLogout = {
authTokenManager.clearToken() 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 is AppScreen.Home -> DashboardScreen( // Route /home to Dashboard for now
authTokenManager = authTokenManager, authTokenManager = authTokenManager,
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
onLogout = { onLogout = {
authTokenManager.clearToken() 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( is AppScreen.CreateTournament -> CreateTournamentScreen(
@@ -66,20 +101,47 @@ fun MainApp() {
onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) } // Later we go to tournament detail onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) } // Later we go to tournament detail
) )
is AppScreen.Login -> LoginScreen( is AppScreen.Login -> {
viewModel = loginViewModel, // wir prüfen hier, woher wir kamen (falls wir das im State hätten)
onLoginSuccess = { navigationPort.navigateToScreen(AppScreen.Dashboard) }, // Im Moment: nach erfolgreichem Login zum returnTo-Screen navigieren, Default ist Dashboard
onBack = { navigationPort.navigateToScreen(AppScreen.Landing) } 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( is AppScreen.Ping -> PingScreen(
viewModel = pingViewModel, viewModel = pingViewModel,
onBack = { navigationPort.navigateToScreen(AppScreen.Dashboard) } onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Always go back to overview
) )
is AppScreen.Profile -> AuthStatusScreen( is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager, 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 -> {} else -> {}
@@ -90,7 +152,8 @@ fun MainApp() {
@Composable @Composable
private fun LandingScreen( private fun LandingScreen(
onPrimaryCta: () -> Unit onPrimaryCta: () -> Unit,
onOpenPing: () -> Unit
) { ) {
val scrollState = rememberScrollState() 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 // Manifest / Intro
Surface(color = MaterialTheme.colorScheme.surfaceVariant) { Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Column( 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 // Footer
AppFooter() AppFooter()
} }
@@ -355,10 +395,24 @@ private fun FeatureCard(number: String, title: String, body: String) {
@Composable @Composable
private fun DashboardScreen( private fun DashboardScreen(
authTokenManager: AuthTokenManager, authTokenManager: AuthTokenManager,
onOpenPing: () -> Unit, onLogout: () -> Unit,
onLogout: () -> Unit onCreateTournament: () -> Unit
) { ) {
val authState by authTokenManager.authState.collectAsState() 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( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@@ -375,7 +429,7 @@ private fun DashboardScreen(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = "Meldestelle Dashboard", text = if (isDesktop) "Master-Meldestelle Steuerungszentrale" else if (isAdmin) "Admin-Dashboard (Web)" else "Veranstalter-Dashboard",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -396,112 +450,351 @@ private fun DashboardScreen(
} }
// Main Content Area // Main Content Area
Row( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(24.dp) 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 if (isDesktop && isAdmin) {
val turniere = listOf( // DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN (DICH)
TournamentData( // Neues Turnier anlegen Button
id = "26128", OutlinedButton(
date = "25. APRIL 2026", onClick = onCreateTournament,
title = "CSN-C NEU CSNP-C NEU", modifier = Modifier.fillMaxWidth().height(64.dp)
location = "NEUMARKT/M., OÖ" ) {
), Text(
TournamentData( text = "+ neues Turnier anlegen",
id = "26129", style = MaterialTheme.typography.titleMedium
date = "26. APRIL 2026",
title = "CDN-C NEU CDNP-C NEU",
location = "NEUMARKT/M., OÖ"
) )
}
// 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( Card(
modifier = Modifier.fillMaxWidth(), 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( Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(), modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column { Column {
Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(
Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium) "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 */ }) { Button(onClick = { /* TODO: Download Trigger for Master App */ }) {
Text("Meldestelle öffnen") Text("Master-Desktop-App herunterladen")
} }
} }
} }
} } else {
// WEB VIEW - VERANSTALTER PORTAL
// DEIN NEUES KONZEPT: Download Desktop App statt "Neues Turnier anlegen" im Web // Top: Aktuelles Turnier & Download
Card( Card(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) 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( Text(
"Master-Meldestelle Desktop App", "Aktuelles Turnier: CDN-C NEU CDNP-C Neumarkt",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) { ) {
Button(onClick = { /* TODO: Download Trigger */ }) { Button(onClick = { /* TODO: Download Trigger for generic app */ }) {
Text("Download für Windows (.exe)") Text("Meldestelle-App herunterladen (.exe)")
} }
// Status Anzeige // Lizenz-Key Anzeige
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Surface( Surface(
modifier = Modifier.size(12.dp),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.surface,
) {} border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
Text("Desktop App derzeit Offline", style = MaterialTheme.typography.labelMedium) ) {
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) @Composable
Column( private fun ToggleRow(label: String, isOnline: Boolean, isInteractive: Boolean = false) {
modifier = Modifier.weight(1f), Surface(
verticalArrangement = Arrangement.spacedBy(16.dp) border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) { shape = MaterialTheme.shapes.small,
Text("System & Tools", style = MaterialTheme.typography.headlineSmall) 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()) { // Fake switch / Status text
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { if (isInteractive) {
OutlinedButton( Surface(
onClick = onOpenPing, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.fillMaxWidth() modifier = Modifier.width(40.dp).height(24.dp)
) { ) {
Text("Ping-Service (System Status)") 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 @Composable
private fun AuthStatusScreen( private fun AuthStatusScreen(
authTokenManager: AuthTokenManager, authTokenManager: AuthTokenManager,
onNavigateToLogin: () -> Unit,
onNavigateToPing: () -> Unit,
onBackToHome: () -> Unit onBackToHome: () -> Unit
) { ) {
val authState by authTokenManager.authState.collectAsState() val authState by authTokenManager.authState.collectAsState()
@@ -907,24 +1202,45 @@ private fun AuthStatusScreen(
.padding(24.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text("Profil / Status", style = MaterialTheme.typography.headlineMedium) Text("Ping-Service / System 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") }
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)) 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 { } else {
Text("Nicht angemeldet.") Text(
"Du bist abgemeldet.",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp)) 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") }
} }
} }
} }
@@ -40,7 +40,7 @@ fun main() = application {
} }
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Equest-Events Master Desktop", title = "Master Desktop",
state = WindowState(width = 1200.dp, height = 800.dp) state = WindowState(width = 1200.dp, height = 800.dp)
) { ) {
MainApp() MainApp()