feat(auth, ui): add Keycloak login flow and enhance LoginScreen UI

- Introduced `onKeycloakLogin` callback in LoginScreen for Keycloak-based authentication.
- Updated Login UI with "Login with Keycloak" button and loading indicator for OIDC flow.
- Implemented dividers and spacing for improved visual structure.

- Refactored LandingScreen into a separate file for better modularity and maintenance.
- Reintroduced LandingScreen features, hero section, and footer with enhanced composables.

- Added `composeHotReload` plugin and `uiTooling` dependency for development improvements.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-21 17:49:15 +01:00
parent faa2c344d1
commit 0e32999df4
4 changed files with 197 additions and 156 deletions

View File

@ -70,7 +70,8 @@ fun LoginScreen(
focusManager = focusManager,
onUsernameChange = viewModel::updateUsername,
onPasswordChange = viewModel::updatePassword,
onLogin = { focusManager.clearFocus(); viewModel.login() }
onLogin = { focusManager.clearFocus(); viewModel.login() },
onKeycloakLogin = viewModel::startOidcFlow
)
} else {
RegisterTabContent()
@ -91,7 +92,8 @@ private fun LoginTabContent(
focusManager: androidx.compose.ui.focus.FocusManager,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLogin: () -> Unit
onLogin: () -> Unit,
onKeycloakLogin: () -> Unit = {},
) {
var passwordVisible by remember { mutableStateOf(false) }
@ -169,6 +171,34 @@ private fun LoginTabContent(
}
}
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
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
OutlinedButton(
onClick = onKeycloakLogin,
enabled = !uiState.isLoading && !uiState.isOidcLoading,
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
if (uiState.isOidcLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
Text("Mit Keycloak anmelden")
}
}
}
}

View File

@ -14,6 +14,7 @@ plugins {
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.composeHotReload)
}
kotlin {
@ -93,6 +94,7 @@ kotlin {
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(compose.uiTooling)
implementation(libs.koin.core)
implementation(project(":frontend:features:nennung-feature"))
}

View File

@ -14,7 +14,6 @@ import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.components.AppFooter
import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.domain.PlatformType
import at.mocode.frontend.core.domain.currentPlatform
@ -168,134 +167,6 @@ fun MainApp() {
}
}
@Composable
private fun LandingScreen(
onPrimaryCta: () -> Unit,
onOpenPing: () -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
// Top Bar area (simple for landing)
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "mo-code.at",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Button(onClick = onPrimaryCta) {
Text("Login Meldestelle")
}
}
}
// Hero
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Die moderne Meldestelle",
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Von Praktikern für Praktiker. Schneller Nennen, fehlerfrei Richten und stressfrei Auswerten konform nach ÖTO & FEI.",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Manifest / Intro
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Unser Anspruch: Ein durchdachtes System.", style = MaterialTheme.typography.headlineMedium)
Text(
"Die Meldestelle ist das Herzstück jedes Turniers. Wenn sie stolpert, stockt der Sport. Wir verstehen den Balanceakt zwischen Veranstaltern, Reitern und den Verbänden.",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Deshalb entwickeln wir diese Plattform nicht am Reißbrett, sondern direkt am Turnierplatz aus der Sicht der Meldestelle, der Richter, der Zeitnehmer und aller Funktionäre.",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Mit Fokus auf die Praxis: Tastaturbedienung für höchste Geschwindigkeit, Offline-Fähigkeit für das 'Plumpsklo' am Rand des Abreiteplatzes und eine integrierte Kassenführung.",
style = MaterialTheme.typography.bodyLarge
)
}
}
// Features
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
Text("Die Kern-Säulen", style = MaterialTheme.typography.headlineMedium)
Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
FeatureCard(
number = "01",
title = "Regelwerks-Intelligenz (ÖTO)",
body = "Wir nehmen Ihnen die Validierungs-Komplexität ab. Von der Lizenzprüfung der Reiter bis zur Kontrolle der Richterqualifikationen beim Anlegen der Bewerbe."
)
FeatureCard(
number = "02",
title = "Offline-First & Resilient",
body = "Stabil auf dem Laptop. Dank Offline-Unterstützung und lokaler Datenbank arbeiten Sie nahtlos weiter, selbst wenn die Internetverbindung am Platz wieder einmal abreißt."
)
FeatureCard(
number = "03",
title = "Speed-Workflow",
body = "Die Nennungsmaske und die Ergebniserfassung sind kompromisslos auf Geschwindigkeit und Tastaturbedienung (Enter & Tab) optimiert. Weil am Turniertag jede Sekunde zählt."
)
FeatureCard(
number = "04",
title = "Smarte Kassenführung",
body = "Kontobasierte Abrechnung für Reiter und Besitzer. Nenngelder, Startgelder und Nachnenngebühren sauber getrennt selbst ein Nennungstausch wird als einfacher Transfer verbucht."
)
}
}
// Ping Service Link
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(onClick = onOpenPing) {
Text("System Status (Ping-Service)")
}
}
// Footer
AppFooter()
}
}
// Data class for dummy tournament
private data class TournamentData(
val id: String,
@ -384,31 +255,6 @@ private fun TournamentCard(data: TournamentData) {
}
}
@Composable
private fun FeatureCard(number: String, title: String, body: String) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = number,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Black,
modifier = Modifier.width(64.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun DashboardScreen(

View File

@ -0,0 +1,163 @@
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.AppFooter
@Composable
fun LandingScreen(
onPrimaryCta: () -> Unit,
onOpenPing: () -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
// Top Bar
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "mo-code.at",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Button(onClick = onPrimaryCta) {
Text("Login Meldestelle")
}
}
}
// Hero
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Die moderne Meldestelle",
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Von Praktikern für Praktiker. Schneller Nennen, fehlerfrei Richten und stressfrei Auswerten konform nach ÖTO & FEI.",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Manifest / Intro
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Unser Anspruch: Ein durchdachtes System.", style = MaterialTheme.typography.headlineMedium)
Text(
"Die Meldestelle ist das Herzstück jedes Turniers. Wenn sie stolpert, stockt der Sport. Wir verstehen den Balanceakt zwischen Veranstaltern, Reitern und den Verbänden.",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Deshalb entwickeln wir diese Plattform nicht am Reißbrett, sondern direkt am Turnierplatz aus der Sicht der Meldestelle, der Richter, der Zeitnehmer und aller Funktionäre.",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Mit Fokus auf die Praxis: Tastaturbedienung für höchste Geschwindigkeit, Offline-Fähigkeit für das 'Plumpsklo' am Rand des Abreiteplatzes und eine integrierte Kassenführung.",
style = MaterialTheme.typography.bodyLarge
)
}
}
// Kern-Säulen
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 60.dp),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
Text("Die Kern-Säulen", style = MaterialTheme.typography.headlineMedium)
Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
LandingFeatureCard(
number = "01",
title = "Regelwerks-Intelligenz (ÖTO)",
body = "Wir nehmen Ihnen die Validierungs-Komplexität ab. Von der Lizenzprüfung der Reiter bis zur Kontrolle der Richterqualifikationen beim Anlegen der Bewerbe."
)
LandingFeatureCard(
number = "02",
title = "Offline-First & Resilient",
body = "Stabil auf dem Laptop. Dank Offline-Unterstützung und lokaler Datenbank arbeiten Sie nahtlos weiter, selbst wenn die Internetverbindung am Platz wieder einmal abreißt."
)
LandingFeatureCard(
number = "03",
title = "Speed-Workflow",
body = "Die Nennungsmaske und die Ergebniserfassung sind kompromisslos auf Geschwindigkeit und Tastaturbedienung (Enter & Tab) optimiert. Weil am Turniertag jede Sekunde zählt."
)
LandingFeatureCard(
number = "04",
title = "Smarte Kassenführung",
body = "Kontobasierte Abrechnung für Reiter und Besitzer. Nenngelder, Startgelder und Nachnenngebühren sauber getrennt selbst ein Nennungstausch wird als einfacher Transfer verbucht."
)
}
}
// System Status Link
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedButton(onClick = onOpenPing) {
Text("System Status (Ping-Service)")
}
}
AppFooter()
}
}
@Composable
private fun LandingFeatureCard(number: String, title: String, body: String) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = number,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Black,
modifier = Modifier.width(64.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}