Add registration tab to LoginScreen and implement OrganizerProfile screen

This commit is contained in:
2026-03-20 23:57:21 +01:00
parent 7f224f0d03
commit 499b93e100
6 changed files with 598 additions and 160 deletions
@@ -326,7 +326,11 @@
"USER", "USER",
"ORGANIZER" "ORGANIZER"
], ],
"clientRoles": {} "clientRoles": {
"api-gateway": [
"ORGANIZER"
]
}
} }
], ],
"groups": [], "groups": [],
@@ -46,19 +46,10 @@ class AuthApiClient(
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "password") append("grant_type", "password")
append("client_id", clientId) append("client_id", clientId)
append("scope", "openid profile email")
// WICHTIG: Senden Sie client_secret nur, wenn es sich NICHT um einen öffentlichen Client (wie 'web-app') handelt. if (!clientSecret.isNullOrBlank()) {
// Keycloak lehnt Anfragen von öffentlichen Clients ab, die client_secret enthalten.
// Wir prüfen, ob die Client-ID auf einen öffentlichen Client hindeutet oder ob ein Secret explizit angegeben wurde.
// Aktuell gehen wir davon aus, dass 'web-app' öffentlich ist und daher kein Secret gesendet werden sollte.
// Logik: Wenn clientId 'web-app' ist, ignorieren wir das Geheimnis oder verlassen uns darauf, dass der Aufrufer null übergibt.
// Da AppConstants möglicherweise noch das Geheimnis für 'postman-client' enthält, ist Vorsicht geboten.
if (!clientSecret.isNullOrBlank() && clientId != "web-app") {
append("client_secret", clientSecret) append("client_secret", clientSecret)
} }
append("username", username) append("username", username)
append("password", password) append("password", password)
} }
@@ -102,7 +93,8 @@ class AuthApiClient(
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "refresh_token") append("grant_type", "refresh_token")
append("client_id", clientId) append("client_id", clientId)
if (!clientSecret.isNullOrBlank() && clientId != "web-app") { append("scope", "openid profile email")
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret) append("client_secret", clientSecret)
} }
append("refresh_token", refreshToken) append("refresh_token", refreshToken)
@@ -45,7 +45,8 @@ enum class Permission {
@Serializable @Serializable
data class JwtPayload( data class JwtPayload(
val sub: String? = null, // User ID val sub: String? = null, // User ID
val username: String? = null, // Username @kotlinx.serialization.SerialName("preferred_username")
val username: String? = null, // Username (Keycloak: preferred_username)
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
@@ -272,7 +273,8 @@ class AuthTokenManager {
if (parts.size != 3) return null if (parts.size != 3) return null
// Decode the payload (second part) // Decode the payload (second part)
val payloadJson = Base64.decode(parts[1]).decodeToString() // JWT uses URL-safe Base64 without padding (RFC 4648 §5)
val payloadJson = Base64.UrlSafe.decode(parts[1]).decodeToString()
// First, try to parse with a standard approach // First, try to parse with a standard approach
val basicPayload = try { val basicPayload = try {
@@ -1,8 +1,9 @@
package at.mocode.frontend.core.auth.presentation package at.mocode.frontend.core.auth.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
@@ -25,16 +26,18 @@ import androidx.compose.ui.unit.dp
fun LoginScreen( fun LoginScreen(
viewModel: LoginViewModel, viewModel: LoginViewModel,
onLoginSuccess: () -> Unit = {}, onLoginSuccess: () -> Unit = {},
onBack: () -> Unit = {} // New callback for back navigation onBack: () -> Unit = {}
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var passwordVisible by remember { mutableStateOf(false) }
// 0 = Anmelden, 1 = Registrieren
var selectedTab by remember { mutableStateOf(0) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Anmelden") }, title = { Text(if (selectedTab == 0) "Anmelden" else "Registrieren") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
@@ -47,82 +50,101 @@ fun LoginScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) {
PrimaryTabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("Anmelden") }
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("Registrieren") }
)
}
if (selectedTab == 0) {
LoginTabContent(
uiState = uiState,
focusManager = focusManager,
onUsernameChange = viewModel::updateUsername,
onPasswordChange = viewModel::updatePassword,
onLogin = { focusManager.clearFocus(); viewModel.login() }
)
} else {
RegisterTabContent()
}
}
}
LaunchedEffect(uiState.isAuthenticated) {
if (uiState.isAuthenticated) {
onLoginSuccess()
}
}
}
@Composable
private fun LoginTabContent(
uiState: LoginUiState,
focusManager: androidx.compose.ui.focus.FocusManager,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLogin: () -> Unit
) {
var passwordVisible by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
// Username field
OutlinedTextField( OutlinedTextField(
value = uiState.username, value = uiState.username,
onValueChange = viewModel::updateUsername, onValueChange = onUsernameChange,
label = { Text("Benutzername") }, label = { Text("Benutzername") },
enabled = !uiState.isLoading && !uiState.isOidcLoading, enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.usernameError != null, isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } }, supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
keyboardType = KeyboardType.Text, keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
.fillMaxWidth()
.padding(bottom = 16.dp)
) )
// Password field
OutlinedTextField( OutlinedTextField(
value = uiState.password, value = uiState.password,
onValueChange = viewModel::updatePassword, onValueChange = onPasswordChange,
label = { Text("Passwort") }, label = { Text("Passwort") },
enabled = !uiState.isLoading && !uiState.isOidcLoading, enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.passwordError != null, isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } }, supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = { trailingIcon = {
val image = if (passwordVisible)
Icons.Filled.Visibility
else
Icons.Filled.VisibilityOff
val description = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen"
IconButton(onClick = { passwordVisible = !passwordVisible }) { IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description) Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen"
)
} }
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardType = KeyboardType.Password, keyboardActions = KeyboardActions(onDone = { if (uiState.canLogin) onLogin() }),
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (uiState.canLogin) {
viewModel.login()
}
}
),
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)
.fillMaxWidth()
.padding(bottom = 24.dp)
) )
// Error message
if (uiState.errorMessage != null) { if (uiState.errorMessage != null) {
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
containerColor = MaterialTheme.colorScheme.errorContainer modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) { ) {
Text( Text(
text = uiState.errorMessage!!, text = uiState.errorMessage,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -131,16 +153,10 @@ fun LoginScreen(
} }
} }
// Login button
Button( Button(
onClick = { onClick = onLogin,
focusManager.clearFocus()
viewModel.login()
},
enabled = uiState.canLogin && !uiState.isLoading, enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier modifier = Modifier.fillMaxWidth().height(48.dp)
.fillMaxWidth()
.height(48.dp)
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -153,48 +169,131 @@ fun LoginScreen(
} }
} }
// Divider }
Row( }
@Composable
private fun RegisterTabContent() {
var displayName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordConfirm by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var passwordConfirmVisible by remember { mutableStateOf(false) }
var registerAttempted by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val displayNameError = if (registerAttempted && displayName.isBlank()) "Name ist erforderlich" else null
val emailError = when {
registerAttempted && email.isBlank() -> "E-Mail ist erforderlich"
registerAttempted && !email.contains("@") -> "Ungültige E-Mail-Adresse"
else -> null
}
val passwordError = if (registerAttempted && password.length < 8) "Mindestens 8 Zeichen erforderlich" else null
val passwordConfirmError = if (registerAttempted && password != passwordConfirm) "Passwörter stimmen nicht überein" else null
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(vertical = 16.dp), .verticalScroll(rememberScrollState())
verticalAlignment = Alignment.CenterVertically .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) { ) {
HorizontalDivider(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = " oder ", text = "Neues Konto erstellen",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp)
) )
HorizontalDivider(modifier = Modifier.weight(1f))
}
// OIDC / Keycloak button OutlinedTextField(
OutlinedButton( value = displayName,
onClick = { viewModel.startOidcFlow() }, onValueChange = { displayName = it },
enabled = !uiState.isLoading && !uiState.isOidcLoading, label = { Text("Vollständiger Name") },
modifier = Modifier isError = displayNameError != null,
.fillMaxWidth() supportingText = displayNameError?.let { { Text(it) } },
.height(48.dp) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail-Adresse") },
isError = emailError != null,
supportingText = emailError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") },
isError = passwordError != null,
supportingText = passwordError?.let { { Text(it) } },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen"
)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
)
OutlinedTextField(
value = passwordConfirm,
onValueChange = { passwordConfirm = it },
label = { Text("Passwort bestätigen") },
isError = passwordConfirmError != null,
supportingText = passwordConfirmError?.let { { Text(it) } },
visualTransformation = if (passwordConfirmVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordConfirmVisible = !passwordConfirmVisible }) {
Icon(
imageVector = if (passwordConfirmVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (passwordConfirmVisible) "Passwort verbergen" else "Passwort anzeigen"
)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); registerAttempted = true }),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)
)
Button(
onClick = {
focusManager.clearFocus()
registerAttempted = true
// TODO: Backend-Anbindung für Registrierung (POST /api/auth/register)
},
modifier = Modifier.fillMaxWidth().height(48.dp)
) { ) {
if (uiState.isOidcLoading) { Text("Konto erstellen")
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Weiterleitung ...")
} else {
Text("Mit Keycloak anmelden")
}
}
}
} }
// Handle login success Spacer(modifier = Modifier.height(12.dp))
LaunchedEffect(uiState.isAuthenticated) {
if (uiState.isAuthenticated) { Text(
onLoginSuccess() text = "Mit der Registrierung stimmst du den Nutzungsbedingungen zu.",
} style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} }
} }
@@ -11,6 +11,7 @@ sealed class AppScreen(val route: String) {
data object Ping : AppScreen("/ping") data object Ping : AppScreen("/ping")
data object Profile : AppScreen("/profile") data object Profile : AppScreen("/profile")
data object OrganizerProfile : AppScreen("/organizer/profile")
data object AuthCallback : AppScreen("/auth/callback") data object AuthCallback : AppScreen("/auth/callback")
companion object { companion object {
@@ -23,6 +24,7 @@ sealed class AppScreen(val route: String) {
Routes.LOGIN, Routes.Auth.LOGIN -> Login() Routes.LOGIN, Routes.Auth.LOGIN -> Login()
"/ping" -> Ping "/ping" -> Ping
"/profile" -> Profile "/profile" -> Profile
"/organizer/profile" -> OrganizerProfile
"/auth/callback" -> AuthCallback "/auth/callback" -> AuthCallback
else -> Landing // Default fallback else -> Landing // Default fallback
} }
@@ -9,6 +9,8 @@ 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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
@@ -65,7 +67,7 @@ fun MainApp() {
} else { } else {
LandingScreen( LandingScreen(
onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) }, // Takes you to Meldestelle login onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) }, // Takes you to Meldestelle login
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Profile) } // Open the Ping Overview / Status page onOpenPing = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.OrganizerProfile)) } // Nach Login zum OrganizerProfile
) )
} }
} }
@@ -124,9 +126,25 @@ fun MainApp() {
is AppScreen.Ping -> PingScreen( is AppScreen.Ping -> PingScreen(
viewModel = pingViewModel, viewModel = pingViewModel,
onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Always go back to overview onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Zurück zum Profil/Übersicht
) )
is AppScreen.OrganizerProfile -> OrganizerProfileScreen(
authTokenManager = authTokenManager,
onLogout = {
authTokenManager.clearToken()
navigationPort.navigateToScreen(AppScreen.Landing)
},
onNavigateToDashboard = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
)
is AppScreen.AuthCallback -> {
// OIDC-Callback: Nach erfolgreichem OAuth-Redirect zum Dashboard navigieren
LaunchedEffect(Unit) {
navigationPort.navigateToScreen(AppScreen.Dashboard)
}
}
is AppScreen.Profile -> AuthStatusScreen( is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager, authTokenManager = authTokenManager,
onNavigateToLogin = { onNavigateToLogin = {
@@ -144,7 +162,7 @@ fun MainApp() {
} }
) )
else -> {} else -> { navigationPort.navigateToScreen(AppScreen.Landing) }
} }
} }
} }
@@ -194,7 +212,7 @@ private fun LandingScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = "Die moderne Turniermeldestelle", text = "Die moderne Meldestelle",
style = MaterialTheme.typography.displayMedium, style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -1188,6 +1206,327 @@ fun TournamentStepBewerbe() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OrganizerProfileScreen(
authTokenManager: AuthTokenManager,
onLogout: () -> Unit,
onNavigateToDashboard: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
val scrollState = rememberScrollState()
// Formular-Felder
var vereinsname by remember { mutableStateOf("URFV Neumarkt") }
var vereinskuerzel by remember { mutableStateOf("URFV") }
var adresse by remember { mutableStateOf("") }
var plz by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
var land by remember { mutableStateOf("Österreich") }
var mapsLink by remember { mutableStateOf("") }
// Ansprechpersonen
var kontakt1Name by remember { mutableStateOf("") }
var kontakt1Email by remember { mutableStateOf("") }
var kontakt1Telefon by remember { mutableStateOf("") }
var kontakt2Name by remember { mutableStateOf("") }
var kontakt2Email by remember { mutableStateOf("") }
var kontakt2Telefon by remember { mutableStateOf("") }
// Social / Links
var webseite by remember { mutableStateOf("") }
var facebook by remember { mutableStateOf("") }
var instagram by remember { mutableStateOf("") }
var youtube by remember { mutableStateOf("") }
// Weitere Infos
var vereinsbeschreibung by remember { mutableStateOf("") }
var bankverbindung by remember { mutableStateOf("") }
var uid by remember { mutableStateOf("") }
var saveSuccess by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Veranstalter Profil") },
navigationIcon = {
IconButton(onClick = onNavigateToDashboard) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
Text(
text = authState.username ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = androidx.compose.ui.Modifier.padding(end = 8.dp)
)
TextButton(onClick = onLogout) { Text("Abmelden") }
}
)
}
) { paddingValues ->
Column(
modifier = androidx.compose.ui.Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// --- Logo & Vereinsname ---
Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) {
Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Verein / Veranstalter", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// Logo Placeholder
Surface(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(
modifier = androidx.compose.ui.Modifier.padding(24.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("🏆", style = MaterialTheme.typography.displayMedium)
Text("Vereins-/Veranstaltungslogo", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = { /* TODO: File Picker */ }) {
Text("Logo hochladen")
}
}
}
OutlinedTextField(
value = vereinsname,
onValueChange = { vereinsname = it },
label = { Text("Vereinsname / Veranstalter") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = vereinskuerzel,
onValueChange = { vereinskuerzel = it },
label = { Text("Kürzel") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = vereinsbeschreibung,
onValueChange = { vereinsbeschreibung = it },
label = { Text("Kurzbeschreibung / Über uns") },
minLines = 3,
maxLines = 6,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
}
}
// --- Adresse ---
Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) {
Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Adresse", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = adresse,
onValueChange = { adresse = it },
label = { Text("Straße & Hausnummer") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.width(100.dp)
)
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.weight(1f)
)
}
OutlinedTextField(
value = land,
onValueChange = { land = it },
label = { Text("Land") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = mapsLink,
onValueChange = { mapsLink = it },
label = { Text("Google Maps / OpenStreetMap Link") },
placeholder = { Text("https://maps.google.com/...") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
}
}
// --- Ansprechpersonen ---
Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) {
Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Ansprechpersonen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Hauptkontakt", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
OutlinedTextField(
value = kontakt1Name,
onValueChange = { kontakt1Name = it },
label = { Text("Name") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt1Email,
onValueChange = { kontakt1Email = it },
label = { Text("E-Mail") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt1Telefon,
onValueChange = { kontakt1Telefon = it },
label = { Text("Telefon / Mobil") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
HorizontalDivider()
Text("Weiterer Kontakt (optional)", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary)
OutlinedTextField(
value = kontakt2Name,
onValueChange = { kontakt2Name = it },
label = { Text("Name") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt2Email,
onValueChange = { kontakt2Email = it },
label = { Text("E-Mail") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt2Telefon,
onValueChange = { kontakt2Telefon = it },
label = { Text("Telefon / Mobil") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
}
}
// --- Social Media & Links ---
Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) {
Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Links & Social Media", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = webseite,
onValueChange = { webseite = it },
label = { Text("Webseite") },
placeholder = { Text("https://www.meinverein.at") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = facebook,
onValueChange = { facebook = it },
label = { Text("Facebook") },
placeholder = { Text("https://facebook.com/...") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = instagram,
onValueChange = { instagram = it },
label = { Text("Instagram") },
placeholder = { Text("https://instagram.com/...") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = youtube,
onValueChange = { youtube = it },
label = { Text("YouTube") },
placeholder = { Text("https://youtube.com/...") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
}
}
// --- Weitere Vereinsdaten ---
Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) {
Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = bankverbindung,
onValueChange = { bankverbindung = it },
label = { Text("IBAN / Bankverbindung") },
placeholder = { Text("AT12 3456 7890 1234 5678") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uid,
onValueChange = { uid = it },
label = { Text("UID-Nummer / ZVR-Zahl") },
singleLine = true,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
)
}
}
// --- Speichern ---
if (saveSuccess) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
) {
Text(
"✓ Profil erfolgreich gespeichert!",
modifier = androidx.compose.ui.Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodyLarge
)
}
}
Button(
onClick = {
// TODO: Backend-Anbindung (PUT /api/organizer/profile)
saveSuccess = true
},
modifier = androidx.compose.ui.Modifier.fillMaxWidth().height(52.dp)
) {
Text("Profil speichern", style = MaterialTheme.typography.titleMedium)
}
OutlinedButton(
onClick = onNavigateToDashboard,
modifier = androidx.compose.ui.Modifier.fillMaxWidth()
) {
Text("Zum Dashboard")
}
Spacer(modifier = androidx.compose.ui.Modifier.height(24.dp))
}
}
}
@Composable @Composable
private fun AuthStatusScreen( private fun AuthStatusScreen(
authTokenManager: AuthTokenManager, authTokenManager: AuthTokenManager,