Compare commits

...

2 Commits

Author SHA1 Message Date
575ef18034 Refactor: Replace androidx.compose.ui.Modifier with Modifier for cleaner code and consistency
All checks were successful
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 8m4s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 7m14s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Successful in 3m17s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m51s
2026-03-21 00:01:53 +01:00
499b93e100 Add registration tab to LoginScreen and implement OrganizerProfile screen 2026-03-20 23:57:21 +01:00
6 changed files with 597 additions and 161 deletions

View File

@ -326,7 +326,11 @@
"USER",
"ORGANIZER"
],
"clientRoles": {}
"clientRoles": {
"api-gateway": [
"ORGANIZER"
]
}
}
],
"groups": [],

View File

@ -46,19 +46,10 @@ class AuthApiClient(
formParameters = Parameters.build {
append("grant_type", "password")
append("client_id", clientId)
// WICHTIG: Senden Sie client_secret nur, wenn es sich NICHT um einen öffentlichen Client (wie 'web-app') handelt.
// 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("scope", "openid profile email")
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("username", username)
append("password", password)
}
@ -102,7 +93,8 @@ class AuthApiClient(
formParameters = Parameters.build {
append("grant_type", "refresh_token")
append("client_id", clientId)
if (!clientSecret.isNullOrBlank() && clientId != "web-app") {
append("scope", "openid profile email")
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("refresh_token", refreshToken)

View File

@ -45,7 +45,8 @@ enum class Permission {
@Serializable
data class JwtPayload(
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 iat: Long? = null, // Issued at timestamp
val iss: String? = null, // Issuer
@ -272,7 +273,8 @@ class AuthTokenManager {
if (parts.size != 3) return null
// 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
val basicPayload = try {

View File

@ -1,8 +1,9 @@
package at.mocode.frontend.core.auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
@ -25,16 +26,18 @@ import androidx.compose.ui.unit.dp
fun LoginScreen(
viewModel: LoginViewModel,
onLoginSuccess: () -> Unit = {},
onBack: () -> Unit = {} // New callback for back navigation
onBack: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current
var passwordVisible by remember { mutableStateOf(false) }
// 0 = Anmelden, 1 = Registrieren
var selectedTab by remember { mutableStateOf(0) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Anmelden") },
title = { Text(if (selectedTab == 0) "Anmelden" else "Registrieren") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
@ -47,154 +50,250 @@ fun LoginScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Username field
OutlinedTextField(
value = uiState.username,
onValueChange = viewModel::updateUsername,
label = { Text("Benutzername") },
enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
// Password field
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text("Passwort") },
enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
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 }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (uiState.canLogin) {
viewModel.login()
}
}
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
)
// Error message
if (uiState.errorMessage != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = uiState.errorMessage!!,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
// Login button
Button(
onClick = {
focusManager.clearFocus()
viewModel.login()
},
enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Anmelden")
}
}
// Divider
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
text = " oder ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
PrimaryTabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("Anmelden") }
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("Registrieren") }
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
// OIDC / Keycloak button
OutlinedButton(
onClick = { viewModel.startOidcFlow() },
enabled = !uiState.isLoading && !uiState.isOidcLoading,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
if (uiState.isOidcLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Weiterleitung ...")
} else {
Text("Mit Keycloak anmelden")
}
if (selectedTab == 0) {
LoginTabContent(
uiState = uiState,
focusManager = focusManager,
onUsernameChange = viewModel::updateUsername,
onPasswordChange = viewModel::updatePassword,
onLogin = { focusManager.clearFocus(); viewModel.login() }
)
} else {
RegisterTabContent()
}
}
}
// Handle login success
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),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = uiState.username,
onValueChange = onUsernameChange,
label = { Text("Benutzername") },
enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
)
OutlinedTextField(
value = uiState.password,
onValueChange = onPasswordChange,
label = { Text("Passwort") },
enabled = !uiState.isLoading && !uiState.isOidcLoading,
isError = uiState.passwordError != null,
supportingText = uiState.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.Done),
keyboardActions = KeyboardActions(onDone = { if (uiState.canLogin) onLogin() }),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)
)
if (uiState.errorMessage != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
) {
Text(
text = uiState.errorMessage,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
Button(
onClick = onLogin,
enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Anmelden")
}
}
}
}
@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
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Neues Konto erstellen",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp)
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Vollständiger Name") },
isError = displayNameError != null,
supportingText = displayNameError?.let { { Text(it) } },
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)
) {
Text("Konto erstellen")
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Mit der Registrierung stimmst du den Nutzungsbedingungen zu.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@ -11,6 +11,7 @@ sealed class AppScreen(val route: String) {
data object Ping : AppScreen("/ping")
data object Profile : AppScreen("/profile")
data object OrganizerProfile : AppScreen("/organizer/profile")
data object AuthCallback : AppScreen("/auth/callback")
companion object {
@ -23,6 +24,7 @@ sealed class AppScreen(val route: String) {
Routes.LOGIN, Routes.Auth.LOGIN -> Login()
"/ping" -> Ping
"/profile" -> Profile
"/organizer/profile" -> OrganizerProfile
"/auth/callback" -> AuthCallback
else -> Landing // Default fallback
}

View File

@ -9,6 +9,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import 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.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
@ -65,7 +67,7 @@ fun MainApp() {
} 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
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.OrganizerProfile)) } // Nach Login zum OrganizerProfile
)
}
}
@ -124,9 +126,25 @@ fun MainApp() {
is AppScreen.Ping -> PingScreen(
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(
authTokenManager = authTokenManager,
onNavigateToLogin = {
@ -143,8 +161,6 @@ fun MainApp() {
}
}
)
else -> {}
}
}
}
@ -194,7 +210,7 @@ private fun LandingScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Die moderne Turniermeldestelle",
text = "Die moderne Meldestelle",
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold
)
@ -1188,6 +1204,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 = Modifier.padding(end = 8.dp)
)
TextButton(onClick = onLogout) { Text("Abmelden") }
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// --- Logo & Vereinsname ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Verein / Veranstalter", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// Logo Placeholder
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(
modifier = 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 = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = vereinsbeschreibung,
onValueChange = { vereinsbeschreibung = it },
label = { Text("Kurzbeschreibung / Über uns") },
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Adresse ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = 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 = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
singleLine = true,
modifier = Modifier.width(100.dp)
)
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = land,
onValueChange = { land = it },
label = { Text("Land") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = mapsLink,
onValueChange = { mapsLink = it },
label = { Text("Google Maps / OpenStreetMap Link") },
placeholder = { Text("https://maps.google.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Ansprechpersonen ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = 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 = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt1Email,
onValueChange = { kontakt1Email = it },
label = { Text("E-Mail") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt1Telefon,
onValueChange = { kontakt1Telefon = it },
label = { Text("Telefon / Mobil") },
singleLine = true,
modifier = 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 = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt2Email,
onValueChange = { kontakt2Email = it },
label = { Text("E-Mail") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = kontakt2Telefon,
onValueChange = { kontakt2Telefon = it },
label = { Text("Telefon / Mobil") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Social Media & Links ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = 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 = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = facebook,
onValueChange = { facebook = it },
label = { Text("Facebook") },
placeholder = { Text("https://facebook.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = instagram,
onValueChange = { instagram = it },
label = { Text("Instagram") },
placeholder = { Text("https://instagram.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = youtube,
onValueChange = { youtube = it },
label = { Text("YouTube") },
placeholder = { Text("https://youtube.com/...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Weitere Vereinsdaten ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = 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 = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uid,
onValueChange = { uid = it },
label = { Text("UID-Nummer / ZVR-Zahl") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
// --- Speichern ---
if (saveSuccess) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
modifier = Modifier.fillMaxWidth()
) {
Text(
"✓ Profil erfolgreich gespeichert!",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodyLarge
)
}
}
Button(
onClick = {
// TODO: Backend-Anbindung (PUT /api/organizer/profile)
saveSuccess = true
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text("Profil speichern", style = MaterialTheme.typography.titleMedium)
}
OutlinedButton(
onClick = onNavigateToDashboard,
modifier = Modifier.fillMaxWidth()
) {
Text("Zum Dashboard")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun AuthStatusScreen(
authTokenManager: AuthTokenManager,