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:
@@ -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 |
+57
-15
@@ -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
-6
@@ -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()
|
||||||
|
|||||||
+46
-53
@@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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 = {
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package at.mocode.frontend.core.domain
|
||||||
|
|
||||||
|
enum class PlatformType {
|
||||||
|
WEB,
|
||||||
|
DESKTOP
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun currentPlatform(): PlatformType
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package at.mocode.frontend.core.domain
|
||||||
|
|
||||||
|
actual fun currentPlatform(): PlatformType = PlatformType.WEB
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package at.mocode.frontend.core.domain
|
||||||
|
|
||||||
|
actual fun currentPlatform(): PlatformType = PlatformType.DESKTOP
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package at.mocode.frontend.core.domain
|
||||||
|
|
||||||
|
actual fun currentPlatform(): PlatformType = PlatformType.WEB
|
||||||
+5
-2
@@ -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 {
|
||||||
|
|||||||
+4
-2
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user