Merge pull request #18

* MP-19 Refactoring: Einführung der "Registry" & "Masterdata" Trennung …

* MP-19 Refactoring: Frontend Tabula Rasa

* MP-19 Refactoring: Frontend Tabula Rasa

* refactoring:

* MP-20 fix(docker/clients): include `:domains` module in web/desktop b…

* MP-20 fix(web-app build): resolve JS compile error and add dev/prod b…

* MP-20 fix(web-app): remove vendor.js reference and harden JS bootstra…

* MP-20 fixing: clients

* MP-20 fixing: clients
This commit is contained in:
StefanMo
2025-11-30 14:13:12 +01:00
committed by GitHub
parent 596a05b69c
commit 9ea2b74a81
254 changed files with 5485 additions and 15971 deletions
+5
View File
@@ -50,3 +50,8 @@ GATEWAY_SERVER_PORT=8081
# --- MICROSERVICES --- # --- MICROSERVICES ---
PING_SERVICE_PORT=8082:8082 PING_SERVICE_PORT=8082:8082
PING_DEBUG_PORT=5006:5006 PING_DEBUG_PORT=5006:5006
# --- CLIENT APPLICATIONS ---
WEB_APP_PORT=4000:4000
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080
+7
View File
@@ -51,3 +51,10 @@ CONSUL_PORT=8500:8500
GATEWAY_PORT=8081 GATEWAY_PORT=8081
# Debug Port für IntelliJ (Remote JVM Debug) # Debug Port für IntelliJ (Remote JVM Debug)
GATEWAY_DEBUG_PORT=5005 GATEWAY_DEBUG_PORT=5005
# --- CLIENT APPLICATIONS ---
# Web-App (Kotlin/JS, kein WASM)
WEB_APP_PORT=4000:4000
# Desktop-App (VNC/noVNC)
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080
+8 -4
View File
@@ -44,7 +44,6 @@ kotlin {
webpackTask { webpackTask {
mainOutputFileName = "web-app.js" mainOutputFileName = "web-app.js"
output.libraryTarget = "commonjs2"
} }
// Development Server konfigurieren // Development Server konfigurieren
@@ -67,7 +66,10 @@ kotlin {
// WASM, nur wenn explizit aktiviert // WASM, nur wenn explizit aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() } wasmJs {
browser()
binaries.executable()
}
} }
sourceSets { sourceSets {
@@ -76,8 +78,8 @@ kotlin {
implementation(project(":clients:shared")) implementation(project(":clients:shared"))
implementation(project(":clients:shared:common-ui")) implementation(project(":clients:shared:common-ui"))
implementation(project(":clients:shared:navigation")) implementation(project(":clients:shared:navigation"))
implementation(project(":clients:auth-feature"))
implementation(project(":clients:ping-feature")) implementation(project(":clients:ping-feature"))
implementation(project(":clients:members-feature"))
// Compose Multiplatform // Compose Multiplatform
implementation(compose.runtime) implementation(compose.runtime)
@@ -129,7 +131,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
jvmTarget.set(JvmTarget.JVM_21) jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
"-Xskip-metadata-version-check" // Für bleeding-edge Versionen "-Xskip-metadata-version-check", // Für bleeding-edge Versionen
// Suppress beta warning for expect/actual declarations used in this module
"-Xexpect-actual-classes"
) )
} }
} }
-3833
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
{
"devDependencies": {
"@types/jest": "^29.2.5",
"jest": "^29.3.1"
},
"scripts": {
"test": "jest"
},
"dependencies": {
"webpack-bundle-analyzer": "^4.10.2"
}
}
+238 -87
View File
@@ -2,107 +2,258 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.clients.membersfeature.ProfileScreen import androidx.compose.runtime.collectAsState
import at.mocode.clients.membersfeature.ProfileViewModel
import at.mocode.clients.shared.navigation.AppScreen import at.mocode.clients.shared.navigation.AppScreen
import at.mocode.clients.authfeature.AuthenticatedHttpClient
import at.mocode.clients.authfeature.AuthTokenManager
import at.mocode.clients.pingfeature.PingScreen
import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.clients.shared.core.AppConstants
import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.text.input.PasswordVisualTransformation
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import at.mocode.clients.authfeature.AuthApiClient
import at.mocode.clients.authfeature.oauth.OAuthPkceService
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
import at.mocode.clients.authfeature.oauth.CallbackParams
@Composable @Composable
fun MainApp() { fun MainApp() {
MaterialTheme { MaterialTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) } var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
when (currentScreen) { val authTokenManager = remember { AuthenticatedHttpClient.getAuthTokenManager() }
is AppScreen.Home -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile }) val pingViewModel = remember { PingViewModel() }
is AppScreen.Login -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile }) val scope = rememberCoroutineScope()
is AppScreen.Ping -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
is AppScreen.Profile -> ProfileScreen(viewModel = remember { ProfileViewModel() }) // Handle PKCE callback on an app load (web)
LaunchedEffect(Unit) {
val callback: CallbackParams? = AuthCallbackParams.parse()
if (callback != null) {
val code = callback.code
val state = callback.state
val pkce = OAuthPkceService.current()
if (pkce != null && pkce.state == state) {
val api = AuthApiClient()
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
val token = res.token
if (res.success && token != null) {
authTokenManager.setToken(token)
OAuthPkceService.clear()
currentScreen = AppScreen.Profile
} }
}
} }
}
when (currentScreen) {
is AppScreen.Home -> WelcomeScreen(
authTokenManager = authTokenManager,
onOpenPing = { AppScreen.Ping },
onOpenLogin = {
// Fallback to the local LoginScreen (Password Grant) if PKCE cannot be started
currentScreen = AppScreen.Login
},
onOpenProfile = { currentScreen = AppScreen.Profile }
)
is AppScreen.Login -> LoginScreen(
authTokenManager = authTokenManager,
onLoginSuccess = { currentScreen = AppScreen.Profile }
)
is AppScreen.Ping -> PingScreen(viewModel = pingViewModel)
is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager,
onBackToHome = { currentScreen = AppScreen.Home }
)
else -> {}
}
} }
}
} }
@Composable @Composable
fun DevelopmentScreen(onOpenProfile: () -> Unit) { private fun WelcomeScreen(
Column( authTokenManager: AuthTokenManager,
modifier = Modifier onOpenPing: () -> Unit,
.fillMaxSize() onOpenLogin: () -> Unit,
.padding(16.dp), onOpenProfile: () -> Unit
verticalArrangement = Arrangement.spacedBy(16.dp) ) {
) { val authState by authTokenManager.authState.collectAsState()
Text( val uriHandler = LocalUriHandler.current
"🚀 Meldestelle Development Mode", val scope = rememberCoroutineScope()
style = MaterialTheme.typography.headlineMedium
)
Card(modifier = Modifier.fillMaxWidth()) { Column(
Column(modifier = Modifier.padding(16.dp)) { modifier = Modifier
Text( .fillMaxSize()
"🌐 Backend Connectivity", .padding(24.dp),
style = MaterialTheme.typography.titleMedium verticalArrangement = Arrangement.spacedBy(16.dp)
) ) {
Text(
text = "Willkommen zur Meldestelle",
style = MaterialTheme.typography.headlineMedium
)
var testStatus by remember { mutableStateOf("Not tested") } // Auth info
Card(modifier = Modifier.fillMaxWidth()) {
Row( Column(modifier = Modifier.padding(16.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp) if (authState.isAuthenticated) {
) { Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
Button(onClick = { testStatus = "Testing Gateway..." }) { Spacer(Modifier.height(8.dp))
Text("Test Gateway") Button(onClick = onOpenProfile) { Text("Profil anzeigen") }
} } else {
Button(onClick = { testStatus = "Testing Ping Service..." }) { Text("Du bist nicht angemeldet.")
Text("Test Ping Service")
}
Button(onClick = onOpenProfile) {
Text("Open Profile")
}
}
Text("Status: $testStatus")
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"🏓 Ping Service Tests",
style = MaterialTheme.typography.titleMedium
)
var isDarkMode by remember { mutableStateOf(false) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = { /* TODO: Health Check */ }) {
Text("Health Check")
}
Button(onClick = { /* TODO: Ping Normal */ }) {
Text("Ping Normal")
}
Button(onClick = { isDarkMode = !isDarkMode }) {
Text("Toggle Dark Mode")
}
}
Text("Dark Mode: ${if(isDarkMode) "🌙 Enabled" else "☀️ Disabled"}")
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"✅ System Status",
style = MaterialTheme.typography.titleMedium
)
Text("Frontend: 🟢 Running")
Text("Backend: ⚠️ Testing needed")
Text("Build: ✅ Successful")
}
} }
}
} }
// Actions
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
if (!authState.isAuthenticated) {
Button(
onClick = {
// Try PKCE login (Authorization Code Flow w/ PKCE)
scope.launch {
try {
val pkce = OAuthPkceService.startAuth()
val url = OAuthPkceService.buildAuthorizeUrl(pkce, AppConstants.webRedirectUri())
uriHandler.openUri(url)
} catch (_: Throwable) {
// Fallback: open the local Login screen (Password Grant)
onOpenLogin()
}
}
},
modifier = Modifier.weight(1f)
) { Text("Login") }
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { uriHandler.openUri(AppConstants.registerUrl()) },
modifier = Modifier.weight(1f)
) { Text("Registrieren (Keycloak)") }
OutlinedButton(
onClick = { uriHandler.openUri(AppConstants.loginUrl()) },
modifier = Modifier.weight(1f)
) { Text("Keycloak Login-Seite") }
}
// Desktop Download Link
OutlinedButton(
onClick = { uriHandler.openUri(AppConstants.desktopDownloadUrl()) },
modifier = Modifier.fillMaxWidth()
) { Text("Desktop-App herunterladen") }
}
}
@Composable
private fun AuthStatusScreen(
authTokenManager: AuthTokenManager,
onBackToHome: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Profil / 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") }
} else {
Text("Nicht angemeldet.")
Spacer(Modifier.height(8.dp))
Button(onClick = onBackToHome) { Text("Zurück zur Startseite") }
}
}
}
}
}
@Composable
private fun LoginScreen(
authTokenManager: AuthTokenManager,
onLoginSuccess: () -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val api = remember { AuthApiClient() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Benutzername") },
singleLine = true,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") },
singleLine = true,
enabled = !isLoading,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
error = null
isLoading = true
scope.launch {
val res = api.login(username.trim(), password)
val token = res.token
if (res.success && token != null) {
authTokenManager.setToken(token)
isLoading = false
onLoginSuccess()
} else {
isLoading = false
error = res.message ?: "Login fehlgeschlagen"
}
}
},
enabled = !isLoading && username.isNotBlank() && password.isNotBlank()
) { Text(if (isLoading) "Bitte warten…" else "Login") }
}
}
} }
@@ -1,65 +0,0 @@
package at.mocode.clients.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import at.mocode.clients.shared.commonui.components.AppHeader
import at.mocode.clients.shared.commonui.components.AppScaffold
import at.mocode.clients.shared.commonui.theme.AppTheme
import at.mocode.clients.shared.navigation.AppScreen
import at.mocode.clients.pingfeature.PingScreen
import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.clients.authfeature.LoginScreen
import at.mocode.clients.authfeature.AuthTokenManager
import androidx.compose.runtime.collectAsState
@Composable
fun App() {
var currentScreen: AppScreen by remember { mutableStateOf(AppScreen.Home) }
// Create a single PingViewModel instance for the lifetime of the App composition.
val pingViewModel: PingViewModel = remember { PingViewModel() }
// Create a single AuthTokenManager instance for the lifetime of the App composition.
val authTokenManager: AuthTokenManager = remember { AuthTokenManager() }
// Observe authentication state
val authState by authTokenManager.authState.collectAsState()
AppTheme {
AppScaffold(
header = {
AppHeader(
title = "Meldestelle",
onNavigateToPing = { currentScreen = AppScreen.Ping },
onNavigateToLogin = { currentScreen = AppScreen.Login },
onLogout = {
authTokenManager.clearToken()
currentScreen = AppScreen.Home
},
isAuthenticated = authState.isAuthenticated,
username = authState.username,
userPermissions = authState.permissions.map { it.name }
)
},
{ paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
when (currentScreen) {
is AppScreen.Home -> {
LandingScreen(authTokenManager = authTokenManager)
}
is AppScreen.Login -> {
LoginScreen(
authTokenManager = authTokenManager,
onLoginSuccess = { currentScreen = AppScreen.Home }
)
}
is AppScreen.Ping -> {
PingScreen(viewModel = pingViewModel)
}
}
}
}
)
}
}
@@ -1,232 +0,0 @@
package at.mocode.clients.app
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.clients.authfeature.AuthTokenManager
import at.mocode.clients.authfeature.Permission
@Composable
fun LandingScreen(
authTokenManager: AuthTokenManager? = null
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(
text = "Willkommen bei Meldestelle",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Eine moderne, skalierbare Frontend-Architektur",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Diese Anwendung demonstriert eine \"Shell + Feature-Module\"-Architektur " +
"basierend auf Kotlin Multiplatform. Sie spiegelt die DDD-Struktur des Backends " +
"wider und ist als native Desktop-Anwendung (JVM) und Web-Anwendung (JS/Wasm) lauffähig.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "🚀 Technologien:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
TechItem("Kotlin Multiplatform")
TechItem("Jetpack Compose Multiplatform")
TechItem("Material Design 3")
TechItem("Ktor Client")
TechItem("Domain-Driven Design")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Verwenden Sie das Ping Service Menü oben, um die API-Funktionalität zu testen.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Permission-based UI demonstration
authTokenManager?.let { tokenManager ->
val authState by tokenManager.authState.collectAsState()
if (authState.isAuthenticated && authState.permissions.isNotEmpty()) {
Spacer(modifier = Modifier.height(32.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "🔐 Verfügbare Funktionen",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.height(16.dp))
// Admin features (visible only to users with delete permissions)
if (tokenManager.isAdmin()) {
PermissionCard(
title = "👑 Administrator-Bereich",
description = "Vollzugriff auf alle System-Funktionen",
permissions = listOf("Alle Berechtigungen", "System-Verwaltung", "Benutzer-Management"),
backgroundColor = MaterialTheme.colorScheme.errorContainer,
textColor = MaterialTheme.colorScheme.onErrorContainer
)
}
// Management features (visible to users with create/update permissions)
if (tokenManager.canCreate() || tokenManager.canUpdate()) {
PermissionCard(
title = "✏️ Verwaltung",
description = "Erstellen und bearbeiten von Daten",
permissions = buildList {
if (tokenManager.hasPermission(Permission.PERSON_CREATE)) add("Personen erstellen")
if (tokenManager.hasPermission(Permission.PERSON_UPDATE)) add("Personen bearbeiten")
if (tokenManager.hasPermission(Permission.VEREIN_CREATE)) add("Vereine erstellen")
if (tokenManager.hasPermission(Permission.VEREIN_UPDATE)) add("Vereine bearbeiten")
if (tokenManager.hasPermission(Permission.PFERD_CREATE)) add("Pferde erstellen")
if (tokenManager.hasPermission(Permission.PFERD_UPDATE)) add("Pferde bearbeiten")
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_CREATE)) add("Veranstaltungen erstellen")
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_UPDATE)) add("Veranstaltungen bearbeiten")
},
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
textColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
// Read-only features (visible to all authenticated users)
if (tokenManager.canRead()) {
PermissionCard(
title = "👁️ Ansicht",
description = "Nur-Lese-Zugriff auf Daten",
permissions = buildList {
if (tokenManager.hasPermission(Permission.PERSON_READ)) add("Personen anzeigen")
if (tokenManager.hasPermission(Permission.VEREIN_READ)) add("Vereine anzeigen")
if (tokenManager.hasPermission(Permission.PFERD_READ)) add("Pferde anzeigen")
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_READ)) add("Veranstaltungen anzeigen")
},
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
textColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
@Composable
private fun TechItem(text: String) {
Text(
text = "• $text",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 2.dp)
)
}
@Composable
private fun PermissionCard(
title: String,
description: String,
permissions: List<String>,
backgroundColor: androidx.compose.ui.graphics.Color,
textColor: androidx.compose.ui.graphics.Color
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = textColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = textColor
)
if (permissions.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
permissions.forEach { permission ->
Text(
text = "✓ $permission",
style = MaterialTheme.typography.bodySmall,
color = textColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
}
}
}
@@ -1,202 +0,0 @@
package screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.presentation.store.AppStore
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
@Composable
fun DevelopmentScreen(appStore: AppStore) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"🚀 Meldestelle Development Mode",
style = MaterialTheme.typography.headlineMedium
)
// Backend Connectivity Tests
BackendTestSection()
// Ping Service Test
PingTestSection()
// State Debugging
StateDebugSection(appStore)
}
}
@Composable
fun BackendTestSection() {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🌐 Backend Connectivity", style = MaterialTheme.typography.titleMedium)
var testStatus by remember { mutableStateOf("Not tested") }
var isLoading by remember { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
// TODO: Test Gateway Connection
isLoading = true
testStatus = "Testing..."
},
enabled = !isLoading
) {
Text("Test Gateway")
}
Button(
onClick = {
// TODO: Test Ping Service Direct
isLoading = true
testStatus = "Testing direct connection..."
},
enabled = !isLoading
) {
Text("Test Ping Service")
}
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
}
Text("Status: $testStatus")
}
}
}
@Composable
fun PingTestSection() {
val pingViewModel = remember { PingViewModel() }
val uiState = pingViewModel.uiState
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🏓 Ping Service Integration", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { pingViewModel.performHealthCheck() },
enabled = !uiState.isLoading
) {
Text("Health Check")
}
Button(
onClick = { pingViewModel.performSimplePing() },
enabled = !uiState.isLoading
) {
Text("Simple Ping")
}
Button(
onClick = { pingViewModel.performEnhancedPing(true) },
enabled = !uiState.isLoading
) {
Text("Test Circuit Breaker")
}
}
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp))
}
// Results Display
uiState.healthResponse?.let { health ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text("✅ Health Check Result:")
Text("Status: ${health.status}")
Text("Service: ${health.service}")
Text("Healthy: ${health.healthy}")
Text("Timestamp: ${health.timestamp}")
}
}
}
uiState.simplePingResponse?.let { ping ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text("🏓 Simple Ping Result:")
Text("Status: ${ping.status}")
Text("Service: ${ping.service}")
Text("Timestamp: ${ping.timestamp}")
}
}
}
uiState.enhancedPingResponse?.let { ping ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text("⚡ Enhanced Ping Result:")
Text("Status: ${ping.status}")
Text("Circuit Breaker: ${ping.circuitBreakerState}")
Text("Response Time: ${ping.responseTime}ms")
Text("Service: ${ping.service}")
}
}
}
uiState.errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
"❌ Error: $error",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(8.dp)
)
}
}
}
}
}
@Composable
fun StateDebugSection(appStore: AppStore) {
val appState by appStore.state.collectAsState()
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🔍 App State Debug", style = MaterialTheme.typography.titleMedium)
Text("Auth State: ${if(appState.auth.isAuthenticated) "✅ Authenticated" else "❌ Not Authenticated"}")
Text("Current Route: ${appState.navigation.currentRoute}")
Text("Dark Mode: ${if(appState.ui.isDarkMode) "🌙 Enabled" else "☀️ Disabled"}")
Text("Online: ${if(appState.network.isOnline) "🟢 Online" else "🔴 Offline"}")
Button(
onClick = {
appStore.dispatch(at.mocode.clients.shared.presentation.actions.AppAction.UI.ToggleDarkMode)
}
) {
Text("Toggle Dark Mode")
}
}
}
}
@@ -0,0 +1,10 @@
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
@@ -1,2 +1,2 @@
actual fun isDevelopmentMode(): Boolean = actual fun isDevelopmentMode(): Boolean =
kotlinx.browser.window.location.hostname == "localhost" kotlinx.browser.window.location.hostname == "localhost"
+28 -12
View File
@@ -1,21 +1,37 @@
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
window.onload = { console.log("[WebApp] main() entered")
try { fun startApp() {
val root = document.getElementById("ComposeTarget") as HTMLElement try {
ComposeViewport(root) { console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
MainApp() val root = document.getElementById("ComposeTarget") as HTMLElement
} console.log("[WebApp] ComposeTarget exists? ", (root != null))
} catch (e: Exception) { ComposeViewport(root) {
console.error("Failed to start Compose Web app", e) MainApp()
document.getElementById("root")?.innerHTML = }
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>" // Remove the static loading placeholder if present
} (document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
console.log("[WebApp] ComposeViewport mounted, loading placeholder removed")
} catch (e: Exception) {
console.error("Failed to start Compose Web app", e)
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
fallbackTarget.innerHTML =
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
} }
}
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded.
val state = document.asDynamic().readyState as String?
if (state == "interactive" || state == "complete") {
console.log("[WebApp] DOM already ready (", state, ") → starting immediately")
startApp()
} else {
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state)
document.addEventListener("DOMContentLoaded", { startApp() })
}
} }
+20 -32
View File
@@ -1,39 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Meldestelle - Web Development</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Meldestelle - Web</title>
<style> <link type="text/css" rel="stylesheet" href="styles.css">
body { <link rel="manifest" href="manifest.webmanifest">
margin: 0; <meta name="theme-color" content="#0f172a">
padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #fafafa;
}
#ComposeTarget {
width: 100vw;
height: 100vh;
}
#root {
width: 100vw;
height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #666;
}
</style>
</head> </head>
<body> <body>
<div id="root"> <div id="ComposeTarget">
<canvas id="ComposeTarget"></canvas> <div class="loading">Loading...</div>
<div class="loading">🚀 Loading Meldestelle...</div> </div>
</div> <script src="web-app.js"></script>
<script src="web-app.js"></script> <script>
// Register Service Worker only in non-localhost environments
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err){
console.warn('ServiceWorker registration failed:', err);
});
});
}
</script>
</body> </body>
</html> </html>
+17 -7
View File
@@ -1,12 +1,22 @@
html, body { html, body {
height: 100vh; margin: 0;
margin: 0; padding: 0;
padding: 0; font-family: system-ui, -apple-system, sans-serif;
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */ background: #fafafa;
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
} }
#ComposeTarget { #ComposeTarget {
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #666;
} }
+4 -2
View File
@@ -58,7 +58,8 @@ self.addEventListener('fetch', (event) => {
.then((resp) => { .then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') { if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone(); const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {}); caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {
});
} }
return resp; return resp;
}) })
@@ -83,7 +84,8 @@ self.addEventListener('fetch', (event) => {
.then((resp) => { .then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') { if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone(); const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {}); caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {
});
} }
return resp; return resp;
}) })
@@ -1,2 +1,2 @@
actual fun isDevelopmentMode(): Boolean = actual fun isDevelopmentMode(): Boolean =
System.getProperty("development.mode", "false").toBoolean() System.getProperty("development.mode", "false").toBoolean()
+7 -7
View File
@@ -4,11 +4,11 @@ import androidx.compose.ui.window.WindowState
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
fun main() = application { fun main() = application {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Meldestelle - Desktop Development", title = "Meldestelle - Desktop Development",
state = WindowState(width = 1200.dp, height = 800.dp) state = WindowState(width = 1200.dp, height = 800.dp)
) { ) {
MainApp() MainApp()
} }
} }
+4 -5
View File
@@ -1,13 +1,12 @@
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import at.mocode.clients.app.App
import kotlinx.browser.document import kotlinx.browser.document
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
val root = document.getElementById("ComposeTarget") as HTMLElement val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) { ComposeViewport(root) {
App() MainApp()
} }
} }
+37 -40
View File
@@ -3,49 +3,46 @@
// Bundle-Analyse für Development (optional, only if package is available) // Bundle-Analyse für Development (optional, only if package is available)
if (process.env.ANALYZE_BUNDLE === 'true') { if (process.env.ANALYZE_BUNDLE === 'true') {
try { try {
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
config.plugins.push(new BundleAnalyzerPlugin({ config.plugins.push(new BundleAnalyzerPlugin({
analyzerMode: 'static', analyzerMode: 'static',
openAnalyzer: false, openAnalyzer: false,
reportFilename: 'bundle-report.html' reportFilename: 'bundle-report.html'
})); }));
console.log('Bundle analyzer enabled'); console.log('Bundle analyzer enabled');
} catch (e) { } catch (e) {
console.log('Bundle analyzer not available (webpack-bundle-analyzer not installed)'); console.log('Bundle analyzer not available (webpack-bundle-analyzer not installed)');
} }
} }
// Weitere Optimierungen hinzufügen (erweitert bestehende config) // Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus.
config.optimization = { // Diese Datei enthält nur einen Script-Tag zu "web-app.js" und wird NICHT
...config.optimization, // Behalte Kotlin/JS Optimierungen // vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime)
splitChunks: { // würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet
chunks: 'all', // (Bildschirm bleibt auf "Loading...").
cacheGroups: { //
vendor: { // Daher überschreiben wir config.optimization NICHT mehr mit splitChunks.
test: /[\\/]node_modules[\\/]/, // Wenn später Chunking gewünscht ist, muss die index.html durch die generierte
name: 'vendor', // HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden.
chunks: 'all' //
} // (Frühere splitChunks-Konfiguration wurde bewusst entfernt.)
}
}
};
// Development Server Konfiguration erweitern // Development Server Konfiguration erweitern
if (config.devServer) { if (config.devServer) {
config.devServer = { config.devServer = {
...config.devServer, ...config.devServer,
historyApiFallback: true, historyApiFallback: true,
hot: true, hot: true,
// API Proxy für Backend-Anfragen (Array-Format für moderne Webpack) // API Proxy für Backend-Anfragen (Array-Format für moderne Webpack)
proxy: [ proxy: [
{ {
context: ['/api'], context: ['/api'],
target: 'http://localhost:8081', target: 'http://localhost:8081',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
pathRewrite: { '^/api': '' } pathRewrite: {'^/api': ''}
} }
] ]
} }
} }
+102 -96
View File
@@ -6,117 +6,123 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features. * und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
*/ */
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
} }
group = "at.mocode.clients" group = "at.mocode.clients"
version = "1.0.0" version = "1.0.0"
kotlin { kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true" val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
js {
browser {
testTask {
enabled = false
}
}
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
commonMain.dependencies {
// UI Kit
implementation(project(":clients:shared:common-ui"))
// Shared Konfig & Utilities (AppConfig + BuildConfig)
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
// Coroutines and serialization
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// DateTime for multiplatform time handling
implementation(libs.kotlinx.datetime)
// ViewModel lifecycle
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
js {
browser {
testTask {
enabled = false
}
}
} }
// WASM, nur wenn explizit aktiviert commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation("io.ktor:ktor-client-mock:${libs.versions.ktor.get()}")
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(libs.ktor.client.auth)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) val wasmJsMain = getByName("wasmJsMain")
wasmJs { browser() } wasmJsMain.dependencies {
} implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
sourceSets { // ✅ HINZUFÜGEN: Compose für shared UI components für WASM
commonMain.dependencies { implementation(compose.runtime)
// UI Kit implementation(compose.foundation)
implementation(project(":clients:shared:common-ui")) implementation(compose.material3)
}
// Shared Konfig & Utilities (AppConfig + BuildConfig)
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
// Coroutines and serialization
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// DateTime for multiplatform time handling
implementation(libs.kotlinx.datetime)
// ViewModel lifecycle
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation("io.ktor:ktor-client-mock:${libs.versions.ktor.get()}")
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(libs.ktor.client.auth)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// ✅ HINZUFÜGEN: Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
} }
}
} }
// KMP Compile-Optionen // KMP Compile-Optionen
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn" "-opt-in=kotlin.RequiresOptIn",
) // Suppress beta warning for expect/actual classes as per project decision
} "-Xexpect-actual-classes"
)
}
} }
@@ -1,11 +1,9 @@
package at.mocode.clients.authfeature package at.mocode.clients.authfeature
import at.mocode.clients.shared.AppConfig import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.* import io.ktor.client.request.forms.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.content.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -13,138 +11,181 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class LoginRequest( data class LoginRequest(
val username: String, val username: String,
val password: String val password: String
) )
@Serializable @Serializable
data class LoginResponse( data class LoginResponse(
val success: Boolean, val success: Boolean,
val token: String? = null, val token: String? = null,
val message: String? = null, val message: String? = null,
val userId: String? = null, val userId: String? = null,
val username: String? = null val username: String? = null
) )
/** /**
* HTTP client for authentication API calls * HTTP client for authentication API calls
*/ */
class AuthApiClient( class AuthApiClient(
// Keycloak Basis-URL (z. B. http://localhost:8180) // Keycloak Basis-URL (z. B. http://localhost:8180)
private val keycloakBaseUrl: String = AppConfig.KEYCLOAK_URL, private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
// Realm-Name in Keycloak // Realm-Name in Keycloak
private val realm: String = AppConfig.KEYCLOAK_REALM, private val realm: String = AppConstants.KEYCLOAK_REALM,
// Client-ID (Public Client empfohlen für Frontend-Flows) // Client-ID (Public Client empfohlen für Frontend-Flows)
private val clientId: String = AppConfig.KEYCLOAK_CLIENT_ID, private val clientId: String = AppConstants.KEYCLOAK_CLIENT_ID,
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich) // Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
private val clientSecret: String? = null private val clientSecret: String? = null
) { ) {
private val client = AuthenticatedHttpClient.createUnauthenticated() private val client = AuthenticatedHttpClient.createUnauthenticated()
/** /**
* Authenticate user with username and password * Authenticate user with username and password
*/ */
suspend fun login(username: String, password: String): LoginResponse { suspend fun login(username: String, password: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try { return try {
val response = client.submitForm( val response = client.submitForm(
url = tokenEndpoint, url = tokenEndpoint,
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "password") append("grant_type", "password")
append("client_id", clientId) append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) { if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret) append("client_secret", clientSecret)
} }
append("username", username) append("username", username)
append("password", password) append("password", password)
}
) {
// Explicit: URL-encoded Form
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null,
userId = null,
username = username
)
} else {
LoginResponse(
success = false,
message = "Login fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Verbindungsfehler: ${e.message}"
)
} }
) {
// Explicit: URL-encoded Form
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null,
userId = null,
username = username
)
} else {
LoginResponse(
success = false,
message = "Login fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Verbindungsfehler: ${e.message}"
)
} }
}
/** /**
* Refresh authentication token * Exchange an authorization code (PKCE) for tokens
*/ */
suspend fun refreshToken(refreshToken: String): LoginResponse { suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token" val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try { return try {
val response = client.submitForm( val response = client.submitForm(
url = tokenEndpoint, url = tokenEndpoint,
formParameters = Parameters.build { formParameters = Parameters.build {
append("grant_type", "refresh_token") append("grant_type", "authorization_code")
append("client_id", clientId) append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) { if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret) append("client_secret", clientSecret)
} }
append("refresh_token", refreshToken) append("code", code)
} append("code_verifier", codeVerifier)
) { append("redirect_uri", redirectUri)
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null
)
} else {
LoginResponse(
success = false,
message = "Token refresh fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Token refresh Fehler: ${e.message}"
)
} }
} ) {
contentType(ContentType.Application.FormUrlEncoded)
}
/** if (response.status.isSuccess()) {
* Logout and invalidate token val kc = response.body<KeycloakTokenResponse>()
*/ LoginResponse(
suspend fun logout(token: String): Boolean { success = true,
// Empfehlung: Frontend-seitig Token lokal verwerfen. token = kc.access_token,
// Optional könnten hier Keycloak-Endpoints für Token-Revocation aufgerufen werden. message = null
return true )
} else {
LoginResponse(
success = false,
message = "Code-Exchange fehlgeschlagen: HTTP ${'$'}{response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Code-Exchange Fehler: ${'$'}{e.message}"
)
} }
}
@Serializable /**
private data class KeycloakTokenResponse( * Refresh authentication token
val access_token: String, */
val expires_in: Long? = null, suspend fun refreshToken(refreshToken: String): LoginResponse {
val refresh_expires_in: Long? = null, val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
val refresh_token: String? = null, return try {
val token_type: String? = null, val response = client.submitForm(
val not_before_policy: Long? = null, url = tokenEndpoint,
val session_state: String? = null, formParameters = Parameters.build {
val scope: String? = null append("grant_type", "refresh_token")
) append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("refresh_token", refreshToken)
}
) {
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null
)
} else {
LoginResponse(
success = false,
message = "Token refresh fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Token refresh Fehler: ${e.message}"
)
}
}
/**
* Logout and invalidate token
*/
suspend fun logout(token: String): Boolean {
// Empfehlung: Frontend-seitig Token lokal verwerfen.
// Optional könnten hier Keycloak-Endpoints für Token-Revocation aufgerufen werden.
return true
}
@Serializable
private data class KeycloakTokenResponse(
val access_token: String,
val expires_in: Long? = null,
val refresh_expires_in: Long? = null,
val refresh_token: String? = null,
val token_type: String? = null,
val not_before_policy: Long? = null,
val session_state: String? = null,
val scope: String? = null
)
} }
@@ -5,12 +5,6 @@ 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.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.contentOrNull
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
@@ -20,29 +14,29 @@ import kotlin.time.ExperimentalTime
*/ */
@Serializable @Serializable
enum class Permission { enum class Permission {
// Person management // Person management
PERSON_READ, PERSON_READ,
PERSON_CREATE, PERSON_CREATE,
PERSON_UPDATE, PERSON_UPDATE,
PERSON_DELETE, PERSON_DELETE,
// Club management // Club management
VEREIN_READ, VEREIN_READ,
VEREIN_CREATE, VEREIN_CREATE,
VEREIN_UPDATE, VEREIN_UPDATE,
VEREIN_DELETE, VEREIN_DELETE,
// Event management // Event management
VERANSTALTUNG_READ, VERANSTALTUNG_READ,
VERANSTALTUNG_CREATE, VERANSTALTUNG_CREATE,
VERANSTALTUNG_UPDATE, VERANSTALTUNG_UPDATE,
VERANSTALTUNG_DELETE, VERANSTALTUNG_DELETE,
// Horse management // Horse management
PFERD_READ, PFERD_READ,
PFERD_CREATE, PFERD_CREATE,
PFERD_UPDATE, PFERD_UPDATE,
PFERD_DELETE PFERD_DELETE
} }
/** /**
@@ -50,23 +44,23 @@ 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 val username: String? = null, // 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
val permissions: List<String>? = null // Permissions array val permissions: List<String>? = null // Permissions array
) )
/** /**
* Authentication state * Authentication state
*/ */
data class AuthState( data class AuthState(
val isAuthenticated: Boolean = false, val isAuthenticated: Boolean = false,
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()
) )
/** /**
@@ -78,267 +72,267 @@ data class AuthState(
*/ */
class AuthTokenManager { class AuthTokenManager {
private var currentToken: String? = null private var currentToken: String? = null
private var tokenPayload: JwtPayload? = null private var tokenPayload: JwtPayload? = null
private val _authState = MutableStateFlow(AuthState()) private val _authState = MutableStateFlow(AuthState())
val authState: StateFlow<AuthState> = _authState.asStateFlow() val authState: StateFlow<AuthState> = _authState.asStateFlow()
/** /**
* Store JWT token in memory * Store JWT token in memory
*/ */
fun setToken(token: String) { fun setToken(token: String) {
currentToken = token currentToken = token
tokenPayload = parseJwtPayload(token) tokenPayload = parseJwtPayload(token)
// Parse permissions from token payload // Parse permissions from token payload
val permissions = tokenPayload?.permissions?.mapNotNull { permissionString -> val permissions = tokenPayload?.permissions?.mapNotNull { permissionString ->
try { try {
Permission.valueOf(permissionString) Permission.valueOf(permissionString)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// Ignore unknown permissions // Ignore unknown permissions
null null
} }
} ?: emptyList() } ?: 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
) )
}
/**
* Get current JWT token
*/
fun getToken(): String? = currentToken
/**
* Check if we have a valid (non-expired) token
*/
@OptIn(ExperimentalTime::class)
fun hasValidToken(): Boolean {
val token = currentToken ?: return false
val payload = tokenPayload ?: return false
// Check expiration
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
return currentTime < expiration
}
/**
* Clear token from memory (logout)
*/
fun clearToken() {
currentToken = null
tokenPayload = null
_authState.value = AuthState()
}
/**
* Get user ID from token
*/
fun getUserId(): String? = tokenPayload?.sub
/**
* Get username from token
*/
fun getUsername(): String? = tokenPayload?.username
/**
* Get current user permissions
*/
fun getPermissions(): List<Permission> = _authState.value.permissions
/**
* Check if user has a specific permission
*/
fun hasPermission(permission: Permission): Boolean {
return _authState.value.permissions.contains(permission)
}
/**
* Check if user has any of the specified permissions
*/
fun hasAnyPermission(vararg permissions: Permission): Boolean {
return permissions.any { _authState.value.permissions.contains(it) }
}
/**
* Check if user has all of the specified permissions
*/
fun hasAllPermissions(vararg permissions: Permission): Boolean {
return permissions.all { _authState.value.permissions.contains(it) }
}
/**
* Check if user can perform read operations
*/
fun canRead(): Boolean {
return hasAnyPermission(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ
)
}
/**
* Check if user can perform create operations
*/
fun canCreate(): Boolean {
return hasAnyPermission(
Permission.PERSON_CREATE,
Permission.VEREIN_CREATE,
Permission.VERANSTALTUNG_CREATE,
Permission.PFERD_CREATE
)
}
/**
* Check if user can perform update operations
*/
fun canUpdate(): Boolean {
return hasAnyPermission(
Permission.PERSON_UPDATE,
Permission.VEREIN_UPDATE,
Permission.VERANSTALTUNG_UPDATE,
Permission.PFERD_UPDATE
)
}
/**
* Check if user can perform delete operations (admin-level)
*/
fun canDelete(): Boolean {
return hasAnyPermission(
Permission.PERSON_DELETE,
Permission.VEREIN_DELETE,
Permission.VERANSTALTUNG_DELETE,
Permission.PFERD_DELETE
)
}
/**
* Check if user is admin (has delete permissions)
*/
fun isAdmin(): Boolean = canDelete()
/**
* Check if token expires within specified minutes
*/
@OptIn(ExperimentalTime::class)
fun isTokenExpiringSoon(minutesThreshold: Int = 5): Boolean {
val payload = tokenPayload ?: return false
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
val thresholdTime = currentTime + (minutesThreshold * 60)
return expiration <= thresholdTime
}
/**
* Parse JWT payload for basic validation and user info extraction
* Note: This is for client-side info extraction only, not security validation
*/
@OptIn(ExperimentalEncodingApi::class)
private fun parseJwtPayload(token: String): JwtPayload? {
return try {
val parts = token.split(".")
if (parts.size != 3) return null
// Decode the payload (second part)
val payloadJson = Base64.decode(parts[1]).decodeToString()
// First try to parse with standard approach
val basicPayload = try {
Json.decodeFromString<JwtPayload>(payloadJson)
} catch (e: Exception) {
// If that fails, extract manually
null
}
// If basic parsing succeeded and has permissions, return it
if (basicPayload != null && basicPayload.permissions != null) {
return basicPayload
}
// Otherwise, extract permissions manually from JSON string
val permissions = extractPermissionsFromJson(payloadJson)
// Return payload with manually extracted permissions
JwtPayload(
sub = basicPayload?.sub,
username = basicPayload?.username,
exp = basicPayload?.exp,
iat = basicPayload?.iat,
iss = basicPayload?.iss,
permissions = permissions
)
} catch (e: Exception) {
// Failed to parse - token might be invalid format
null
}
}
/**
* Extract permissions array from JSON string using simple string parsing
*/
private fun extractPermissionsFromJson(jsonString: String): List<String>? {
return try {
// Simple regex to find permissions array
val permissionsRegex = """"permissions":\s*\[(.*?)\]""".toRegex()
val match = permissionsRegex.find(jsonString)
match?.let {
val permissionsContent = it.groupValues[1]
if (permissionsContent.isBlank()) return emptyList()
// Extract individual permission strings
val permissions = permissionsContent
.split(",")
.mapNotNull { permission ->
permission.trim()
.removePrefix("\"")
.removeSuffix("\"")
.takeIf { it.isNotBlank() }
}
permissions
}
} catch (e: Exception) {
null
}
}
/**
* Get token with Bearer prefix for HTTP headers
*/
fun getBearerToken(): String? {
val token = getToken() ?: return null
return "Bearer $token"
}
/**
* Refresh token if needed based on expiry
*/
suspend fun refreshTokenIfNeeded(authApiClient: AuthApiClient): Boolean {
if (!isTokenExpiringSoon()) return true
val currentToken = getToken() ?: return false
val refreshResponse = authApiClient.refreshToken(currentToken)
if (refreshResponse.success && refreshResponse.token != null) {
setToken(refreshResponse.token)
return true
} }
/** // Refresh failed, clear token
* Get current JWT token clearToken()
*/ return false
fun getToken(): String? = currentToken }
/**
* Check if we have a valid (non-expired) token
*/
@OptIn(ExperimentalTime::class)
fun hasValidToken(): Boolean {
val token = currentToken ?: return false
val payload = tokenPayload ?: return false
// Check expiration
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
return currentTime < expiration
}
/**
* Clear token from memory (logout)
*/
fun clearToken() {
currentToken = null
tokenPayload = null
_authState.value = AuthState()
}
/**
* Get user ID from token
*/
fun getUserId(): String? = tokenPayload?.sub
/**
* Get username from token
*/
fun getUsername(): String? = tokenPayload?.username
/**
* Get current user permissions
*/
fun getPermissions(): List<Permission> = _authState.value.permissions
/**
* Check if user has a specific permission
*/
fun hasPermission(permission: Permission): Boolean {
return _authState.value.permissions.contains(permission)
}
/**
* Check if user has any of the specified permissions
*/
fun hasAnyPermission(vararg permissions: Permission): Boolean {
return permissions.any { _authState.value.permissions.contains(it) }
}
/**
* Check if user has all of the specified permissions
*/
fun hasAllPermissions(vararg permissions: Permission): Boolean {
return permissions.all { _authState.value.permissions.contains(it) }
}
/**
* Check if user can perform read operations
*/
fun canRead(): Boolean {
return hasAnyPermission(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ
)
}
/**
* Check if user can perform create operations
*/
fun canCreate(): Boolean {
return hasAnyPermission(
Permission.PERSON_CREATE,
Permission.VEREIN_CREATE,
Permission.VERANSTALTUNG_CREATE,
Permission.PFERD_CREATE
)
}
/**
* Check if user can perform update operations
*/
fun canUpdate(): Boolean {
return hasAnyPermission(
Permission.PERSON_UPDATE,
Permission.VEREIN_UPDATE,
Permission.VERANSTALTUNG_UPDATE,
Permission.PFERD_UPDATE
)
}
/**
* Check if user can perform delete operations (admin-level)
*/
fun canDelete(): Boolean {
return hasAnyPermission(
Permission.PERSON_DELETE,
Permission.VEREIN_DELETE,
Permission.VERANSTALTUNG_DELETE,
Permission.PFERD_DELETE
)
}
/**
* Check if user is admin (has delete permissions)
*/
fun isAdmin(): Boolean = canDelete()
/**
* Check if token expires within specified minutes
*/
@OptIn(ExperimentalTime::class)
fun isTokenExpiringSoon(minutesThreshold: Int = 5): Boolean {
val payload = tokenPayload ?: return false
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
val thresholdTime = currentTime + (minutesThreshold * 60)
return expiration <= thresholdTime
}
/**
* Parse JWT payload for basic validation and user info extraction
* Note: This is for client-side info extraction only, not security validation
*/
@OptIn(ExperimentalEncodingApi::class)
private fun parseJwtPayload(token: String): JwtPayload? {
return try {
val parts = token.split(".")
if (parts.size != 3) return null
// Decode the payload (second part)
val payloadJson = Base64.decode(parts[1]).decodeToString()
// First try to parse with standard approach
val basicPayload = try {
Json.decodeFromString<JwtPayload>(payloadJson)
} catch (e: Exception) {
// If that fails, extract manually
null
}
// If basic parsing succeeded and has permissions, return it
if (basicPayload != null && basicPayload.permissions != null) {
return basicPayload
}
// Otherwise, extract permissions manually from JSON string
val permissions = extractPermissionsFromJson(payloadJson)
// Return payload with manually extracted permissions
JwtPayload(
sub = basicPayload?.sub,
username = basicPayload?.username,
exp = basicPayload?.exp,
iat = basicPayload?.iat,
iss = basicPayload?.iss,
permissions = permissions
)
} catch (e: Exception) {
// Failed to parse - token might be invalid format
null
}
}
/**
* Extract permissions array from JSON string using simple string parsing
*/
private fun extractPermissionsFromJson(jsonString: String): List<String>? {
return try {
// Simple regex to find permissions array
val permissionsRegex = """"permissions":\s*\[(.*?)\]""".toRegex()
val match = permissionsRegex.find(jsonString)
match?.let {
val permissionsContent = it.groupValues[1]
if (permissionsContent.isBlank()) return emptyList()
// Extract individual permission strings
val permissions = permissionsContent
.split(",")
.mapNotNull { permission ->
permission.trim()
.removePrefix("\"")
.removeSuffix("\"")
.takeIf { it.isNotBlank() }
}
permissions
}
} catch (e: Exception) {
null
}
}
/**
* Get token with Bearer prefix for HTTP headers
*/
fun getBearerToken(): String? {
val token = getToken() ?: return null
return "Bearer $token"
}
/**
* Refresh token if needed based on expiry
*/
suspend fun refreshTokenIfNeeded(authApiClient: AuthApiClient): Boolean {
if (!isTokenExpiringSoon()) return true
val currentToken = getToken() ?: return false
val refreshResponse = authApiClient.refreshToken(currentToken)
if (refreshResponse.success && refreshResponse.token != null) {
setToken(refreshResponse.token)
return true
}
// Refresh failed, clear token
clearToken()
return false
}
} }
@@ -1,6 +1,6 @@
package at.mocode.clients.authfeature package at.mocode.clients.authfeature
import at.mocode.clients.shared.AppConfig import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
@@ -14,49 +14,49 @@ import kotlinx.serialization.json.Json
*/ */
object AuthenticatedHttpClient { object AuthenticatedHttpClient {
private val authTokenManager = AuthTokenManager() private val authTokenManager = AuthTokenManager()
/** /**
* Create a basic HTTP client with JSON support * Create a basic HTTP client with JSON support
*/ */
fun create(baseUrl: String = AppConfig.GATEWAY_URL): HttpClient { fun create(baseUrl: String = AppConstants.GATEWAY_URL): HttpClient {
return HttpClient { return HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
prettyPrint = true prettyPrint = true
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
}) })
} }
}
} }
}
/** /**
* Add an authentication header to an HTTP request builder if a token is available * Add an authentication header to an HTTP request builder if a token is available
*/ */
fun HttpRequestBuilder.addAuthHeader() { fun HttpRequestBuilder.addAuthHeader() {
authTokenManager.getBearerToken()?.let { bearerToken -> authTokenManager.getBearerToken()?.let { bearerToken ->
header(HttpHeaders.Authorization, bearerToken) header(HttpHeaders.Authorization, bearerToken)
}
} }
}
/** /**
* Get the shared AuthTokenManager instance * Get the shared AuthTokenManager instance
*/ */
fun getAuthTokenManager(): AuthTokenManager = authTokenManager fun getAuthTokenManager(): AuthTokenManager = authTokenManager
/** /**
* Create an HTTP client without authentication (for login/public endpoints) * Create an HTTP client without authentication (for login/public endpoints)
*/ */
fun createUnauthenticated(): HttpClient { fun createUnauthenticated(): HttpClient {
return HttpClient { return HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
prettyPrint = true prettyPrint = true
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
}) })
} }
}
} }
}
} }
@@ -19,118 +19,118 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen( fun LoginScreen(
authTokenManager: AuthTokenManager, authTokenManager: AuthTokenManager,
viewModel: LoginViewModel = viewModel { LoginViewModel(authTokenManager) }, viewModel: LoginViewModel = viewModel { LoginViewModel(authTokenManager) },
onLoginSuccess: () -> Unit = {} onLoginSuccess: () -> Unit = {}
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val passwordFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() }
Column( Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Title
Text(
text = "Anmelden",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 32.dp)
)
// Username field
OutlinedTextField(
value = uiState.username,
onValueChange = viewModel::updateUsername,
label = { Text("Benutzername") },
enabled = !uiState.isLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
// Password field
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text("Passwort") },
enabled = !uiState.isLoading,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (uiState.canLogin) {
viewModel.login()
}
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.padding(bottom = 24.dp)
)
// Error message
if (uiState.errorMessage != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(24.dp), .padding(bottom = 16.dp)
horizontalAlignment = Alignment.CenterHorizontally, ) {
verticalArrangement = Arrangement.Center
) {
// Title
Text( Text(
text = "Anmelden", text = uiState.errorMessage!!,
style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onErrorContainer,
color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 32.dp) textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
) )
}
// Username field
OutlinedTextField(
value = uiState.username,
onValueChange = viewModel::updateUsername,
label = { Text("Benutzername") },
enabled = !uiState.isLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
// Password field
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text("Passwort") },
enabled = !uiState.isLoading,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (uiState.canLogin) {
viewModel.login()
}
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.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 = { 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")
}
}
} }
// Handle login success // Login button
LaunchedEffect(uiState.isAuthenticated) { Button(
if (uiState.isAuthenticated) { onClick = { viewModel.login() },
onLoginSuccess() 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")
}
} }
}
// Handle login success
LaunchedEffect(uiState.isAuthenticated) {
if (uiState.isAuthenticated) {
onLoginSuccess()
}
}
} }
@@ -2,131 +2,130 @@ package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.clients.shared.AppConfig import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
import io.ktor.client.call.* import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.request.* import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow 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.coroutines.launch import kotlinx.coroutines.launch
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
/** /**
* UI state for the login screen * UI state for the login screen
*/ */
data class LoginUiState( data class LoginUiState(
val username: String = "", val username: String = "",
val password: String = "", val password: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isAuthenticated: Boolean = false, val isAuthenticated: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val usernameError: String? = null, val usernameError: String? = null,
val passwordError: String? = null val passwordError: String? = null
) { ) {
val canLogin: Boolean val canLogin: Boolean
get() = username.isNotBlank() && password.isNotBlank() && !isLoading get() = username.isNotBlank() && password.isNotBlank() && !isLoading
} }
/** /**
* ViewModel for handling login authentication logic * ViewModel for handling login authentication logic
*/ */
class LoginViewModel( class LoginViewModel(
private val authTokenManager: AuthTokenManager private val authTokenManager: AuthTokenManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState()) private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val authApiClient = AuthApiClient() private val authApiClient = AuthApiClient()
fun updateUsername(username: String) { fun updateUsername(username: String) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
username = username, username = username,
usernameError = null, usernameError = null,
errorMessage = null errorMessage = null
) )
}
fun updatePassword(password: String) {
_uiState.value = _uiState.value.copy(
password = password,
passwordError = null,
errorMessage = null
)
}
fun login() {
val currentState = _uiState.value
// Validate input
if (currentState.username.isBlank()) {
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
return
} }
fun updatePassword(password: String) { if (currentState.password.isBlank()) {
_uiState.value = _uiState.value.copy( _uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
password = password, return
passwordError = null,
errorMessage = null
)
} }
fun login() { // Start the login process
val currentState = _uiState.value _uiState.value = currentState.copy(
isLoading = true,
errorMessage = null,
usernameError = null,
passwordError = null
)
// Validate input viewModelScope.launch {
if (currentState.username.isBlank()) { try {
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich") val loginResponse = authApiClient.login(
return username = currentState.username,
} password = currentState.password
if (currentState.password.isBlank()) {
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
return
}
// Start the login process
_uiState.value = currentState.copy(
isLoading = true,
errorMessage = null,
usernameError = null,
passwordError = null
) )
viewModelScope.launch { if (loginResponse.success && loginResponse.token != null) {
// Store the JWT token
authTokenManager.setToken(loginResponse.token)
_uiState.value = _uiState.value.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null
)
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
viewModelScope.launch {
try { try {
val loginResponse = authApiClient.login( val client = AuthenticatedHttpClient.create()
username = currentState.username, client.post("${AppConstants.GATEWAY_URL}/api/members/sync") {
password = currentState.password addAuthHeader()
) }
} catch (_: Exception) {
if (loginResponse.success && loginResponse.token != null) { // Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
// Store the JWT token
authTokenManager.setToken(loginResponse.token)
_uiState.value = _uiState.value.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null
)
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
viewModelScope.launch {
try {
val client = AuthenticatedHttpClient.create()
client.post("${AppConfig.GATEWAY_URL}/api/members/sync") {
addAuthHeader()
}
} catch (_: Exception) {
// Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
}
}
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loginResponse.message ?: "Anmeldung fehlgeschlagen"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Verbindungsfehler: ${e.message}"
)
} }
}
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loginResponse.message ?: "Anmeldung fehlgeschlagen"
)
} }
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Verbindungsfehler: ${e.message}"
)
}
} }
}
fun logout() { fun logout() {
authTokenManager.clearToken() authTokenManager.clearToken()
_uiState.value = LoginUiState() _uiState.value = LoginUiState()
} }
fun checkAuthenticationStatus() { fun checkAuthenticationStatus() {
val isAuthenticated = authTokenManager.hasValidToken() val isAuthenticated = authTokenManager.hasValidToken()
_uiState.value = _uiState.value.copy(isAuthenticated = isAuthenticated) _uiState.value = _uiState.value.copy(isAuthenticated = isAuthenticated)
} }
} }
@@ -0,0 +1,12 @@
package at.mocode.clients.authfeature.oauth
data class CallbackParams(val code: String, val state: String?)
expect object AuthCallbackParams {
/**
* Parse OAuth callback parameters from the current environment.
* - JS (web): reads window.location.search for `code` and `state` and removes them from the URL.
* - JVM (desktop): returns null.
*/
fun parse(): CallbackParams?
}
@@ -0,0 +1,34 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
data class PkceState(
val state: String,
val codeVerifier: String,
val codeChallenge: String,
val method: String = "S256"
)
object OAuthParams {
const val RESPONSE_TYPE = "code"
const val SCOPE = "openid"
}
/**
* expect/actual service to support PKCE across JS and JVM.
* For the desktop (JVM) target we currently do not start a browser flow,
* but we provide hashing to keep API parity.
*/
expect object OAuthPkceService {
/** Starts a PKCE auth attempt and stores transient state in memory. */
suspend fun startAuth(): PkceState
/** Returns currently active state if any (not persisted). */
fun current(): PkceState?
/** Clears transient state (after success/failure). */
fun clear()
/** Builds the authorize URL for the current state. */
fun buildAuthorizeUrl(state: PkceState, redirectUri: String = AppConstants.webRedirectUri()): String
}
@@ -0,0 +1,19 @@
package at.mocode.clients.authfeature.oauth
import kotlinx.browser.window
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? {
val search = window.location.search
if (search.isBlank()) return null
val params = js("new URLSearchParams(arguments[0])").unsafeCast<(String) -> dynamic>()(search)
val code = params.get("code") as String?
val state = params.get("state") as String?
return if (!code.isNullOrBlank()) {
// Clean up query params to avoid re-processing on recomposition
val url = window.location.origin + window.location.pathname
window.history.replaceState(null, "", url)
CallbackParams(code, state)
} else null
}
}
@@ -0,0 +1,81 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
import kotlinx.browser.window
import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Uint8Array
import kotlin.js.Promise
import kotlin.random.Random
private var currentPkce: PkceState? = null
private fun base64UrlFromBytes(bytes: ByteArray): String {
// Build binary string from bytes
val sb = StringBuilder(bytes.size)
for (b in bytes) sb.append(b.toInt().toChar())
val b64 = window.btoa(sb.toString())
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun base64UrlFromArrayBuffer(buf: ArrayBuffer): String {
val arr = Uint8Array(buf)
var binary = ""
val len = arr.length
for (i in 0 until len) {
val v = (arr.asDynamic()[i] as Number).toInt()
binary += fromCharCode(v)
}
val b64 = window.btoa(binary)
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun randomUrlSafe(length: Int): String {
val bytes = Random.Default.nextBytes(length)
// Use base64url for entropy; ensure URL-safe by replacing padding removed already
return base64UrlFromBytes(bytes)
}
private fun sha256(input: String): Promise<ArrayBuffer> {
val enc: dynamic = js("new TextEncoder()")
val data = enc.encode(input)
val subtle: dynamic = window.asDynamic().crypto.subtle
return subtle.digest("SHA-256", data) as Promise<ArrayBuffer>
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val challengeBuf = sha256(codeVerifier).await()
val codeChallenge = base64UrlFromArrayBuffer(challengeBuf)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkce = pkce
return pkce
}
actual fun current(): PkceState? = currentPkce
actual fun clear() {
currentPkce = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + encodeURIComponent(v) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
@Suppress("UnsafeCastFromDynamic")
private fun encodeURIComponent(value: String): String = js("encodeURIComponent")(value)
@Suppress("UnsafeCastFromDynamic")
private fun fromCharCode(code: Int): String = js("String.fromCharCode")(code)
@@ -0,0 +1,5 @@
package at.mocode.clients.authfeature.oauth
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? = null
}
@@ -0,0 +1,55 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
private var currentPkceJvm: PkceState? = null
private fun base64UrlNoPad(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
private fun randomUrlSafe(length: Int): String {
// Generate bytes and Base64 URL encode (will be > length due to encoding)
val rnd = SecureRandom()
val bytes = ByteArray(length)
rnd.nextBytes(bytes)
return base64UrlNoPad(bytes)
}
private fun sha256Base64Url(input: String): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return base64UrlNoPad(digest)
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val codeChallenge = sha256Base64Url(codeVerifier)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkceJvm = pkce
return pkce
}
actual fun current(): PkceState? = currentPkceJvm
actual fun clear() {
currentPkceJvm = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + java.net.URLEncoder.encode(v, Charsets.UTF_8) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
-87
View File
@@ -1,87 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser {
testTask { enabled = false }
}
}
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
}
sourceSets {
commonMain.dependencies {
// UI Kit
implementation(project(":clients:shared:common-ui"))
// Shared config/utilities
implementation(project(":clients:shared"))
// Authentication helpers (token + authenticated client)
implementation(project(":clients:auth-feature"))
// Compose
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// ViewModel lifecycle + compose helpers
implementation(libs.bundles.compose.common)
// HTTP + Kotlinx
implementation(libs.bundles.ktor.client.common)
implementation(libs.bundles.kotlinx.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn")
}
}
@@ -1,37 +0,0 @@
package at.mocode.clients.membersfeature
import at.mocode.clients.authfeature.AuthenticatedHttpClient
import at.mocode.clients.shared.AppConfig
import at.mocode.clients.membersfeature.model.MemberProfile
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
class MembersApiClient(
private val baseUrl: String = AppConfig.GATEWAY_URL
) {
private val client = AuthenticatedHttpClient.create()
suspend fun getMyProfile(): MemberProfile {
// Erwarteter Endpoint: GET /api/members/me
return client.get("$baseUrl/api/members/me") {
addAuthHeader()
}.body()
}
/**
* Optionaler Convenience-Call: Löst den Backend-Sync einmalig aus.
* Gibt true zurück, wenn der Call erfolgreich war (HTTP 2xx), sonst false.
*/
suspend fun syncProfile(): Boolean {
return try {
val response = client.post("$baseUrl/api/members/sync") {
addAuthHeader()
}
response.status.isSuccess()
} catch (_: Exception) {
false
}
}
}
@@ -1,63 +0,0 @@
package at.mocode.clients.membersfeature
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadProfile()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "👤 Mein Profil", style = MaterialTheme.typography.headlineSmall)
if (state.isLoading) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.errorMessage?.let { error ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Fehler", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(error)
Spacer(Modifier.height(8.dp))
Button(onClick = { viewModel.clearError() }) { Text("Schließen") }
}
}
}
state.profile?.let { profile ->
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = profile.fullName.ifBlank { "Unbekannt" }, style = MaterialTheme.typography.titleLarge)
profile.username?.let { Text("Benutzername: $it") }
profile.email?.let { Text("E-Mail: $it") }
if (profile.roles.isNotEmpty()) {
Text("Rollen: ${profile.roles.joinToString(", ")}")
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.loadProfile() }, enabled = !state.isLoading) {
Text("Neu laden")
}
}
}
}
@@ -1,42 +0,0 @@
package at.mocode.clients.membersfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.membersfeature.model.MemberProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: MemberProfile? = null,
val errorMessage: String? = null
)
class ProfileViewModel(
private val api: MembersApiClient = MembersApiClient()
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
viewModelScope.launch {
try {
val profile = api.getMyProfile()
_uiState.value = ProfileUiState(isLoading = false, profile = profile)
} catch (e: Exception) {
_uiState.value = ProfileUiState(
isLoading = false,
errorMessage = e.message ?: "Profil konnte nicht geladen werden"
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}
@@ -1,16 +0,0 @@
package at.mocode.clients.membersfeature.model
import kotlinx.serialization.Serializable
@Serializable
data class MemberProfile(
val id: String? = null,
val username: String? = null,
val email: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val roles: List<String> = emptyList()
) {
val fullName: String
get() = listOfNotNull(firstName, lastName).joinToString(" ").ifBlank { username ?: "" }
}
+94 -90
View File
@@ -6,111 +6,115 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features. * und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
*/ */
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
} }
group = "at.mocode.clients" group = "at.mocode.clients"
version = "1.0.0" version = "1.0.0"
kotlin { kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true" val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
js {
browser {
testTask {
enabled = false
}
}
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
commonMain.dependencies {
// Contract from backend
implementation(projects.services.ping.pingApi)
// UI Kit
implementation(project(":clients:shared:common-ui"))
// Shared Konfig & Utilities
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.bundles.ktor.client.common)
// Coroutines and serialization
implementation(libs.bundles.kotlinx.core)
// ViewModel lifecycle
implementation(libs.bundles.compose.common)
js {
browser {
testTask {
enabled = false
}
}
} }
// WASM, nur wenn explizit aktiviert commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
// Auth-Models Zugriff (nur für JVM)
//implementation(project(":infrastructure:auth:auth-client"))
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) val wasmJsMain = getByName("wasmJsMain")
wasmJs { browser() } wasmJsMain.dependencies {
} implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
sourceSets { // ✅ HINZUFÜGEN: Compose für shared UI components für WASM
commonMain.dependencies { implementation(compose.runtime)
// Contract from backend implementation(compose.foundation)
implementation(projects.services.ping.pingApi) implementation(compose.material3)
}
// UI Kit
implementation(project(":clients:shared:common-ui"))
// Shared Konfig & Utilities
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.bundles.ktor.client.common)
// Coroutines and serialization
implementation(libs.bundles.kotlinx.core)
// ViewModel lifecycle
implementation(libs.bundles.compose.common)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
// Auth-Models Zugriff (nur für JVM)
//implementation(project(":infrastructure:auth:auth-client"))
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// ✅ HINZUFÜGEN: Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
} }
}
} }
// KMP Compile-Optionen // KMP Compile-Optionen
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn" "-opt-in=kotlin.RequiresOptIn"
) )
} }
} }
@@ -4,7 +4,7 @@ import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse import at.mocode.ping.api.HealthResponse
import at.mocode.clients.shared.AppConfig import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
@@ -13,30 +13,30 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class PingApiClient( class PingApiClient(
private val baseUrl: String = AppConfig.GATEWAY_URL private val baseUrl: String = AppConstants.GATEWAY_URL
) : PingApi { ) : PingApi {
private val client = HttpClient { private val client = HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
prettyPrint = true prettyPrint = true
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
}) })
}
} }
}
override suspend fun simplePing(): PingResponse { override suspend fun simplePing(): PingResponse {
return client.get("$baseUrl/api/ping/simple").body() return client.get("$baseUrl/api/ping/simple").body()
} }
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
return client.get("$baseUrl/api/ping/enhanced") { return client.get("$baseUrl/api/ping/enhanced") {
parameter("simulate", simulate) parameter("simulate", simulate)
}.body() }.body()
} }
override suspend fun healthCheck(): HealthResponse { override suspend fun healthCheck(): HealthResponse {
return client.get("$baseUrl/api/ping/health").body() return client.get("$baseUrl/api/ping/health").body()
} }
} }
@@ -21,288 +21,288 @@ import at.mocode.clients.pingfeature.model.RoleCategory
@Composable @Composable
fun PingScreen(viewModel: PingViewModel) { fun PingScreen(viewModel: PingViewModel) {
val uiState = viewModel.uiState val uiState = viewModel.uiState
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(16.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Ping Service",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Button(
text = "Ping Service", onClick = { viewModel.performSimplePing() },
style = MaterialTheme.typography.headlineMedium, enabled = !uiState.isLoading,
fontWeight = FontWeight.Bold modifier = Modifier.weight(1f)
) ) {
Text("Simple Ping")
}
// Action Buttons Button(
Row( onClick = { viewModel.performEnhancedPing() },
modifier = Modifier.fillMaxWidth(), enabled = !uiState.isLoading,
horizontalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier.weight(1f)
) { ) {
Button( Text("Enhanced Ping")
onClick = { viewModel.performSimplePing() }, }
enabled = !uiState.isLoading,
modifier = Modifier.weight(1f)
) {
Text("Simple Ping")
}
Button( Button(
onClick = { viewModel.performEnhancedPing() }, onClick = { viewModel.performHealthCheck() },
enabled = !uiState.isLoading, enabled = !uiState.isLoading,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Enhanced Ping") Text("Health Check")
} }
Button(
onClick = { viewModel.performHealthCheck() },
enabled = !uiState.isLoading,
modifier = Modifier.weight(1f)
) {
Text("Health Check")
}
}
// Loading indicator
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Error message
uiState.errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.clearError() }
) {
Text("Dismiss")
}
}
}
}
// Simple Ping Response
uiState.simplePingResponse?.let { response ->
ResponseCard(
title = "Simple Ping Response",
status = response.status,
timestamp = response.timestamp,
service = response.service
)
}
// Enhanced Ping Response
uiState.enhancedPingResponse?.let { response ->
ResponseCard(
title = "Enhanced Ping Response",
status = response.status,
timestamp = response.timestamp,
service = response.service,
additionalInfo = mapOf(
"Circuit Breaker State" to response.circuitBreakerState,
"Response Time" to "${response.responseTime}ms"
)
)
}
// Health Response
uiState.healthResponse?.let { response ->
ResponseCard(
title = "Health Check Response",
status = response.status,
timestamp = response.timestamp,
service = response.service,
additionalInfo = mapOf(
"Healthy" to response.healthy.toString()
)
)
}
// Neue Reitsport-Authentication-Sektion
Spacer(modifier = Modifier.height(24.dp))
ReitsportTestingSection(
viewModel = viewModel,
uiState = uiState
)
} }
// Loading indicator
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Error message
uiState.errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.clearError() }
) {
Text("Dismiss")
}
}
}
}
// Simple Ping Response
uiState.simplePingResponse?.let { response ->
ResponseCard(
title = "Simple Ping Response",
status = response.status,
timestamp = response.timestamp,
service = response.service
)
}
// Enhanced Ping Response
uiState.enhancedPingResponse?.let { response ->
ResponseCard(
title = "Enhanced Ping Response",
status = response.status,
timestamp = response.timestamp,
service = response.service,
additionalInfo = mapOf(
"Circuit Breaker State" to response.circuitBreakerState,
"Response Time" to "${response.responseTime}ms"
)
)
}
// Health Response
uiState.healthResponse?.let { response ->
ResponseCard(
title = "Health Check Response",
status = response.status,
timestamp = response.timestamp,
service = response.service,
additionalInfo = mapOf(
"Healthy" to response.healthy.toString()
)
)
}
// Neue Reitsport-Authentication-Sektion
Spacer(modifier = Modifier.height(24.dp))
ReitsportTestingSection(
viewModel = viewModel,
uiState = uiState
)
}
} }
@Composable @Composable
private fun ResponseCard( private fun ResponseCard(
title: String, title: String,
status: String, status: String,
timestamp: String, timestamp: String,
service: String, service: String,
additionalInfo: Map<String, String> = emptyMap() additionalInfo: Map<String, String> = emptyMap()
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceVariant
) )
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Column( Text(
modifier = Modifier.padding(16.dp), text = title,
verticalArrangement = Arrangement.spacedBy(8.dp) style = MaterialTheme.typography.titleMedium,
) { fontWeight = FontWeight.Bold
Text( )
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
InfoRow("Status", status) InfoRow("Status", status)
InfoRow("Timestamp", timestamp) InfoRow("Timestamp", timestamp)
InfoRow("Service", service) InfoRow("Service", service)
additionalInfo.forEach { (key, value) -> additionalInfo.forEach { (key, value) ->
InfoRow(key, value) InfoRow(key, value)
} }
}
} }
}
} }
@Composable @Composable
private fun InfoRow(label: String, value: String) { private fun InfoRow(label: String, value: String) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = "$label:", text = "$label:",
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
Text(text = value) Text(text = value)
} }
} }
@Composable @Composable
private fun ReitsportTestingSection( private fun ReitsportTestingSection(
viewModel: PingViewModel, viewModel: PingViewModel,
uiState: PingUiState uiState: PingUiState
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = MaterialTheme.colorScheme.secondaryContainer
) )
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Column( // Header
modifier = Modifier.padding(16.dp), Row(
verticalArrangement = Arrangement.spacedBy(12.dp) modifier = Modifier.fillMaxWidth(),
) { verticalAlignment = Alignment.CenterVertically
// Header ) {
Row( Text(
modifier = Modifier.fillMaxWidth(), text = "🐎",
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.headlineMedium
) { )
Text( Spacer(modifier = Modifier.width(8.dp))
text = "🐎", Text(
style = MaterialTheme.typography.headlineMedium text = "Reitsport-Authentication-Testing",
) style = MaterialTheme.typography.headlineSmall,
Spacer(modifier = Modifier.width(8.dp)) fontWeight = FontWeight.Bold
Text( )
text = "Reitsport-Authentication-Testing", }
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
}
Text( Text(
text = "Teste verschiedene Benutzerrollen und ihre Berechtigungen im Meldestelle_Pro System", text = "Teste verschiedene Benutzerrollen und ihre Berechtigungen im Meldestelle_Pro System",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
) )
// Rollen-Grid // Rollen-Grid
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp), columns = GridCells.Adaptive(minSize = 120.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(200.dp) // Feste Höhe für 2 Reihen modifier = Modifier.height(200.dp) // Feste Höhe für 2 Reihen
) { ) {
items(ReitsportRoles.ALL_ROLES) { role -> items(ReitsportRoles.ALL_ROLES) { role ->
RoleTestButton( RoleTestButton(
role = role, role = role,
onClick = { viewModel.testReitsportRole(role) }, onClick = { viewModel.testReitsportRole(role) },
isLoading = uiState.isLoading isLoading = uiState.isLoading
) )
}
}
} }
}
} }
}
} }
@Composable @Composable
private fun RoleTestButton( private fun RoleTestButton(
role: ReitsportRole, role: ReitsportRole,
onClick: () -> Unit, onClick: () -> Unit,
isLoading: Boolean isLoading: Boolean
) { ) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
enabled = !isLoading, enabled = !isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(80.dp), .height(80.dp),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = when (role.category) { contentColor = when (role.category) {
RoleCategory.SYSTEM -> Color(0xFFFF5722) RoleCategory.SYSTEM -> Color(0xFFFF5722)
RoleCategory.OFFICIAL -> Color(0xFF3F51B5) RoleCategory.OFFICIAL -> Color(0xFF3F51B5)
RoleCategory.ACTIVE -> Color(0xFF4CAF50) RoleCategory.ACTIVE -> Color(0xFF4CAF50)
RoleCategory.PASSIVE -> Color(0xFF9E9E9E) RoleCategory.PASSIVE -> Color(0xFF9E9E9E)
} }
) )
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Column( Text(
horizontalAlignment = Alignment.CenterHorizontally, text = role.icon,
verticalArrangement = Arrangement.Center fontSize = 20.sp
) { )
Text( Text(
text = role.icon, text = role.displayName.split(" ").first(), // Erstes Wort nur
fontSize = 20.sp fontSize = 10.sp,
) fontWeight = FontWeight.Medium,
Text( textAlign = TextAlign.Center,
text = role.displayName.split(" ").first(), // Erstes Wort nur maxLines = 1
fontSize = 10.sp, )
fontWeight = FontWeight.Medium, Text(
textAlign = TextAlign.Center, text = "${role.permissions.size} Rechte",
maxLines = 1 fontSize = 8.sp,
) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
Text( textAlign = TextAlign.Center
text = "${role.permissions.size} Rechte", )
fontSize = 8.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
} }
}
} }
@@ -15,136 +15,136 @@ import at.mocode.ping.api.PingResponse
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class PingUiState( data class PingUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val simplePingResponse: PingResponse? = null, val simplePingResponse: PingResponse? = null,
val enhancedPingResponse: EnhancedPingResponse? = null, val enhancedPingResponse: EnhancedPingResponse? = null,
val healthResponse: HealthResponse? = null, val healthResponse: HealthResponse? = null,
val errorMessage: String? = null val errorMessage: String? = null
) )
class PingViewModel( class PingViewModel(
private val apiClient: PingApi = PingApiClient() private val apiClient: PingApi = PingApiClient()
) : ViewModel() { ) : ViewModel() {
var uiState by mutableStateOf(PingUiState()) var uiState by mutableStateOf(PingUiState())
private set private set
fun performSimplePing() { fun performSimplePing() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true, errorMessage = null)
try { try {
val response = apiClient.simplePing() val response = apiClient.simplePing()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
simplePingResponse = response simplePingResponse = response
) )
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
errorMessage = "Simple ping failed: ${e.message}" errorMessage = "Simple ping failed: ${e.message}"
) )
} }
} }
} }
fun performEnhancedPing(simulate: Boolean = false) { fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true, errorMessage = null)
try { try {
val response = apiClient.enhancedPing(simulate) val response = apiClient.enhancedPing(simulate)
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
enhancedPingResponse = response enhancedPingResponse = response
) )
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
errorMessage = "Enhanced ping failed: ${e.message}" errorMessage = "Enhanced ping failed: ${e.message}"
) )
} }
} }
} }
fun performHealthCheck() { fun performHealthCheck() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null) uiState = uiState.copy(isLoading = true, errorMessage = null)
try { try {
val response = apiClient.healthCheck() val response = apiClient.healthCheck()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
healthResponse = response healthResponse = response
) )
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
errorMessage = "Health check failed: ${e.message}" errorMessage = "Health check failed: ${e.message}"
) )
} }
} }
} }
fun clearError() { fun clearError() {
uiState = uiState.copy(errorMessage = null) uiState = uiState.copy(errorMessage = null)
} }
/** /**
* Erweiterte Methode: Echte API-Tests für Reitsport-Rollen * Erweiterte Methode: Echte API-Tests für Reitsport-Rollen
*/ */
fun testReitsportRole(role: ReitsportRole) { fun testReitsportRole(role: ReitsportRole) {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy( uiState = uiState.copy(
isLoading = true, isLoading = true,
errorMessage = null errorMessage = null
) )
try { try {
// Echte API-Tests durchführen // Echte API-Tests durchführen
val apiClient = ReitsportTestApi() val apiClient = ReitsportTestApi()
val testResults = apiClient.testRole(role) val testResults = apiClient.testRole(role)
// Erfolgs-Statistiken berechnen // Erfolgs-Statistiken berechnen
val successful = testResults.count { it.success } val successful = testResults.count { it.success }
val total = testResults.size val total = testResults.size
val successRate = if (total > 0) (successful * 100 / total) else 0 val successRate = if (total > 0) (successful * 100 / total) else 0
// Test-Summary erstellen // Test-Summary erstellen
val summary = buildString { val summary = buildString {
appendLine("🎯 ${role.displayName} - Test Abgeschlossen") appendLine("🎯 ${role.displayName} - Test Abgeschlossen")
appendLine("📊 Erfolgsrate: $successful/$total Tests ($successRate%)") appendLine("📊 Erfolgsrate: $successful/$total Tests ($successRate%)")
appendLine("⏱️ Durchschnittsdauer: ${testResults.map { it.duration }.average().toInt()}ms") appendLine("⏱️ Durchschnittsdauer: ${testResults.map { it.duration }.average().toInt()}ms")
appendLine("🔑 Berechtigungen: ${role.permissions.size}") appendLine("🔑 Berechtigungen: ${role.permissions.size}")
appendLine("") appendLine("")
appendLine("📋 Test-Ergebnisse:") appendLine("📋 Test-Ergebnisse:")
testResults.forEach { result -> testResults.forEach { result ->
val icon = if (result.success) "✅" else "❌" val icon = if (result.success) "✅" else "❌"
val status = if (result.responseCode != null) " (${result.responseCode})" else "" val status = if (result.responseCode != null) " (${result.responseCode})" else ""
appendLine("$icon ${result.scenarioName}$status - ${result.duration}ms") appendLine("$icon ${result.scenarioName}$status - ${result.duration}ms")
} }
}
// Mock-Response für Anzeige
val mockResponse = PingResponse(
status = summary,
timestamp = DateTimeHelper.formatDateTime(DateTimeHelper.now()),
service = "Reitsport-Auth-Test"
)
uiState = uiState.copy(
isLoading = false,
simplePingResponse = mockResponse
)
println("[DEBUG] Reitsport-API-Test: ${role.displayName}")
println("[DEBUG] Ergebnisse: $successful/$total erfolgreich")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Reitsport-API-Test fehlgeschlagen: ${e.message}"
)
println("[ERROR] Reitsport-Test-Fehler: ${e.message}")
}
} }
// Mock-Response für Anzeige
val mockResponse = PingResponse(
status = summary,
timestamp = DateTimeHelper.formatDateTime(DateTimeHelper.now()),
service = "Reitsport-Auth-Test"
)
uiState = uiState.copy(
isLoading = false,
simplePingResponse = mockResponse
)
println("[DEBUG] Reitsport-API-Test: ${role.displayName}")
println("[DEBUG] Ergebnisse: $successful/$total erfolgreich")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Reitsport-API-Test fehlgeschlagen: ${e.message}"
)
println("[ERROR] Reitsport-Test-Fehler: ${e.message}")
}
} }
}
} }
@@ -8,254 +8,258 @@ import kotlinx.coroutines.delay
/** /**
* API-Client für Reitsport-Authentication-Testing * API-Client für Reitsport-Authentication-Testing
* Testet verschiedene Services mit rollenbasierten Tokens * testet verschiedene Services mit rollenbasierten Tokens
*/ */
class ReitsportTestApi { class ReitsportTestApi {
companion object { companion object {
// URLs der verfügbaren Services // URLs der verfügbaren Services
private const val PING_SERVICE_URL = "http://localhost:8082" private const val PING_SERVICE_URL = "http://localhost:8082"
private const val GATEWAY_URL = "http://localhost:8081" private const val GATEWAY_URL = "http://localhost:8081"
// Mock URLs für auskommentierte Services // Mock URLs für auskommentierte Services
private const val MEMBERS_SERVICE_URL = "http://localhost:8083" // Auskommentiert private const val MEMBERS_SERVICE_URL = "http://localhost:8083" // Auskommentiert
private const val HORSES_SERVICE_URL = "http://localhost:8084" // Auskommentiert private const val HORSES_SERVICE_URL = "http://localhost:8084" // Auskommentiert
private const val EVENTS_SERVICE_URL = "http://localhost:8085" // Auskommentiert private const val EVENTS_SERVICE_URL = "http://localhost:8085" // Auskommentiert
}
/**
* Teste eine Rolle gegen verfügbare Services
*/
suspend fun testRole(role: ReitsportRole): List<ApiTestResult> {
val results = mutableListOf<ApiTestResult>()
// 1. Test Ping-Service (immer verfügbar)
results.add(testPingService(role))
// 2. Test Gateway Health (immer verfügbar)
results.add(testGatewayHealth(role))
// 3. Test rollenspezifische Services
when (role.roleType) {
RolleE.ADMIN, RolleE.VEREINS_ADMIN -> {
results.add(testMembersService(role))
results.add(testSystemAccess(role))
}
RolleE.FUNKTIONAER -> {
results.add(testEventsService(role))
results.add(testMembersService(role))
}
RolleE.TIERARZT, RolleE.TRAINER -> {
results.add(testHorsesService(role))
}
RolleE.REITER -> {
results.add(testMembersService(role))
}
RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> {
results.add(testPublicAccess(role))
}
} }
/** return results
* Teste eine Rolle gegen verfügbare Services }
*/
suspend fun testRole(role: ReitsportRole): List<ApiTestResult> {
val results = mutableListOf<ApiTestResult>()
// 1. Test Ping-Service (immer verfügbar) /**
results.add(testPingService(role)) * Test 1: Ping-Service (verfügbar)
*/
private suspend fun testPingService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
// 2. Test Gateway Health (immer verfügbar) return try {
results.add(testGatewayHealth(role)) // Simuliere HTTP-Call zum Ping-Service
delay(200)
// 3. Test rollenspezifische Services val duration = DateTimeHelper.now() - startTime
when (role.roleType) { val endpoint = "$PING_SERVICE_URL/health"
RolleE.ADMIN, RolleE.VEREINS_ADMIN -> {
results.add(testMembersService(role))
results.add(testSystemAccess(role))
}
RolleE.FUNKTIONAER -> {
results.add(testEventsService(role))
results.add(testMembersService(role))
}
RolleE.TIERARZT, RolleE.TRAINER -> {
results.add(testHorsesService(role))
}
RolleE.REITER -> {
results.add(testMembersService(role))
}
RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> {
results.add(testPublicAccess(role))
}
}
return results ApiTestResult(
scenarioId = "ping-health",
scenarioName = "Ping Service Health",
endpoint = endpoint,
method = "GET",
expectedResult = "Service erreichbar",
actualResult = "✅ Ping-Service läuft (HTTP 200)",
success = true,
responseCode = 200,
duration = duration,
token = generateMockToken(role),
responseData = """{"status":"pong","service":"ping-service","healthy":true}"""
)
} catch (e: Exception) {
ApiTestResult(
scenarioId = "ping-health",
scenarioName = "Ping Service Health",
endpoint = "$PING_SERVICE_URL/health",
method = "GET",
expectedResult = "Service erreichbar",
actualResult = "❌ Fehler: ${e.message}",
success = false,
duration = DateTimeHelper.now() - startTime,
errorMessage = e.message
)
} }
}
/** /**
* Test 1: Ping-Service (verfügbar) * Test 2: Gateway Health (verfügbar)
*/ */
private suspend fun testPingService(role: ReitsportRole): ApiTestResult { private suspend fun testGatewayHealth(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now() val startTime = DateTimeHelper.now()
return try { return try {
// Simuliere HTTP-Call zum Ping-Service delay(150)
delay(200)
val duration = DateTimeHelper.now() - startTime val duration = DateTimeHelper.now() - startTime
val endpoint = "$PING_SERVICE_URL/health" val endpoint = "$GATEWAY_URL/actuator/health"
ApiTestResult( ApiTestResult(
scenarioId = "ping-health", scenarioId = "gateway-health",
scenarioName = "Ping Service Health", scenarioName = "API Gateway Health",
endpoint = endpoint, endpoint = endpoint,
method = "GET", method = "GET",
expectedResult = "Service erreichbar", expectedResult = "Gateway gesund",
actualResult = "✅ Ping-Service läuft (HTTP 200)", actualResult = "✅ Gateway erreichbar, Service Discovery aktiv",
success = true, success = true,
responseCode = 200, responseCode = 200,
duration = duration, duration = duration,
token = generateMockToken(role), token = generateMockToken(role),
responseData = """{"status":"pong","service":"ping-service","healthy":true}""" responseData = """{"status":"UP","components":{"consul":{"status":"UP"}}}"""
) )
} catch (e: Exception) { } catch (e: Exception) {
ApiTestResult( ApiTestResult(
scenarioId = "ping-health", scenarioId = "gateway-health",
scenarioName = "Ping Service Health", scenarioName = "API Gateway Health",
endpoint = "$PING_SERVICE_URL/health", endpoint = "$GATEWAY_URL/actuator/health",
method = "GET", method = "GET",
expectedResult = "Service erreichbar", expectedResult = "Gateway gesund",
actualResult = "❌ Fehler: ${e.message}", actualResult = "❌ Gateway nicht erreichbar: ${e.message}",
success = false, success = false,
duration = DateTimeHelper.now() - startTime, duration = DateTimeHelper.now() - startTime,
errorMessage = e.message errorMessage = e.message
) )
}
} }
}
/** /**
* Test 2: Gateway Health (verfügbar) * Test 3: Members-Service (auskommentiert - Graceful Degradation)
*/ */
private suspend fun testGatewayHealth(role: ReitsportRole): ApiTestResult { private suspend fun testMembersService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now() val startTime = DateTimeHelper.now()
delay(100)
return try { return ApiTestResult(
delay(150) scenarioId = "members-unavailable",
scenarioName = "Members Service",
endpoint = "$MEMBERS_SERVICE_URL/api/members",
method = "GET",
expectedResult = "Mitglieder-Daten abrufen",
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false,
responseCode = 503, // Service Unavailable
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role),
errorMessage = "Service ist in der aktuellen Konfiguration nicht verfügbar"
)
}
val duration = DateTimeHelper.now() - startTime /**
val endpoint = "$GATEWAY_URL/actuator/health" * Test 4: Horses-Service (auskommentiert)
*/
private suspend fun testHorsesService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(100)
ApiTestResult( return ApiTestResult(
scenarioId = "gateway-health", scenarioId = "horses-unavailable",
scenarioName = "API Gateway Health", scenarioName = "Horses Service",
endpoint = endpoint, endpoint = "$HORSES_SERVICE_URL/api/horses",
method = "GET", method = "GET",
expectedResult = "Gateway gesund", expectedResult = "Pferde-Daten abrufen",
actualResult = "✅ Gateway erreichbar, Service Discovery aktiv", actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = true, success = false,
responseCode = 200, responseCode = 503,
duration = duration, duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role), token = generateMockToken(role),
responseData = """{"status":"UP","components":{"consul":{"status":"UP"}}}""" errorMessage = "Service wird später aktiviert"
) )
} catch (e: Exception) { }
ApiTestResult(
scenarioId = "gateway-health",
scenarioName = "API Gateway Health",
endpoint = "$GATEWAY_URL/actuator/health",
method = "GET",
expectedResult = "Gateway gesund",
actualResult = "❌ Gateway nicht erreichbar: ${e.message}",
success = false,
duration = DateTimeHelper.now() - startTime,
errorMessage = e.message
)
}
}
/** /**
* Test 3: Members-Service (auskommentiert - Graceful Degradation) * Test 5: Events-Service (auskommentiert)
*/ */
private suspend fun testMembersService(role: ReitsportRole): ApiTestResult { private suspend fun testEventsService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now() val startTime = DateTimeHelper.now()
delay(100) delay(100)
return ApiTestResult( return ApiTestResult(
scenarioId = "members-unavailable", scenarioId = "events-unavailable",
scenarioName = "Members Service", scenarioName = "Events Service",
endpoint = "$MEMBERS_SERVICE_URL/api/members", endpoint = "$EVENTS_SERVICE_URL/api/events",
method = "GET", method = "GET",
expectedResult = "Mitglieder-Daten abrufen", expectedResult = "Veranstaltungs-Daten abrufen",
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)", actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false, success = false,
responseCode = 503, // Service Unavailable responseCode = 503,
duration = DateTimeHelper.now() - startTime, duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role), token = generateMockToken(role),
errorMessage = "Service ist in der aktuellen Konfiguration nicht verfügbar" errorMessage = "Service in Entwicklung"
) )
} }
/** /**
* Test 4: Horses-Service (auskommentiert) * Test 6: System-Zugriff (für Admins)
*/ */
private suspend fun testHorsesService(role: ReitsportRole): ApiTestResult { private suspend fun testSystemAccess(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now() val startTime = DateTimeHelper.now()
delay(100) delay(300)
return ApiTestResult( val hasSystemAccess = role.roleType == RolleE.ADMIN
scenarioId = "horses-unavailable",
scenarioName = "Horses Service",
endpoint = "$HORSES_SERVICE_URL/api/horses",
method = "GET",
expectedResult = "Pferde-Daten abrufen",
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false,
responseCode = 503,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role),
errorMessage = "Service wird später aktiviert"
)
}
/** return ApiTestResult(
* Test 5: Events-Service (auskommentiert) scenarioId = "system-access",
*/ scenarioName = "System-Administration",
private suspend fun testEventsService(role: ReitsportRole): ApiTestResult { endpoint = "$GATEWAY_URL/actuator/info",
val startTime = DateTimeHelper.now() method = "GET",
delay(100) expectedResult = if (hasSystemAccess) "System-Info verfügbar" else "Zugriff verweigert",
actualResult = if (hasSystemAccess) "✅ System-Informationen zugänglich" else "❌ Insufficient permissions",
success = hasSystemAccess,
responseCode = if (hasSystemAccess) 200 else 403,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role)
)
}
return ApiTestResult( /**
scenarioId = "events-unavailable", * Test 7: Öffentlicher Zugriff
scenarioName = "Events Service", */
endpoint = "$EVENTS_SERVICE_URL/api/events", private suspend fun testPublicAccess(role: ReitsportRole): ApiTestResult {
method = "GET", val startTime = DateTimeHelper.now()
expectedResult = "Veranstaltungs-Daten abrufen", delay(150)
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false,
responseCode = 503,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role),
errorMessage = "Service in Entwicklung"
)
}
/** return ApiTestResult(
* Test 6: System-Zugriff (für Admins) scenarioId = "public-access",
*/ scenarioName = "Öffentliche Informationen",
private suspend fun testSystemAccess(role: ReitsportRole): ApiTestResult { endpoint = "$GATEWAY_URL/api/public/info",
val startTime = DateTimeHelper.now() method = "GET",
delay(300) expectedResult = "Öffentliche Daten verfügbar",
actualResult = "✅ Öffentliche Informationen zugänglich (kein Token erforderlich)",
success = true,
responseCode = 200,
duration = DateTimeHelper.now() - startTime,
token = null // Kein Token für öffentlichen Zugriff
)
}
val hasSystemAccess = role.roleType == RolleE.ADMIN /**
* Generiere Mock-Token für Tests
return ApiTestResult( */
scenarioId = "system-access", private fun generateMockToken(role: ReitsportRole): String {
scenarioName = "System-Administration", // Phase 3: Mock-Token (später echte Keycloak-Integration)
endpoint = "$GATEWAY_URL/actuator/info", val mockPayload = """{"role":"${role.roleType}","permissions":${role.permissions.size}}"""
method = "GET", return "mock.token.${DateTimeHelper.now()}.${role.roleType}"
expectedResult = if (hasSystemAccess) "System-Info verfügbar" else "Zugriff verweigert", }
actualResult = if (hasSystemAccess) "✅ System-Informationen zugänglich" else "❌ Insufficient permissions",
success = hasSystemAccess,
responseCode = if (hasSystemAccess) 200 else 403,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role)
)
}
/**
* Test 7: Öffentlicher Zugriff
*/
private suspend fun testPublicAccess(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(150)
return ApiTestResult(
scenarioId = "public-access",
scenarioName = "Öffentliche Informationen",
endpoint = "$GATEWAY_URL/api/public/info",
method = "GET",
expectedResult = "Öffentliche Daten verfügbar",
actualResult = "✅ Öffentliche Informationen zugänglich (kein Token erforderlich)",
success = true,
responseCode = 200,
duration = DateTimeHelper.now() - startTime,
token = null // Kein Token für öffentlichen Zugriff
)
}
/**
* Generiere Mock-Token für Tests
*/
private fun generateMockToken(role: ReitsportRole): String {
// Phase 3: Mock-Token (später echte Keycloak-Integration)
val mockPayload = """{"role":"${role.roleType}","permissions":${role.permissions.size}}"""
return "mock.token.${DateTimeHelper.now()}.${role.roleType}"
}
} }
@@ -8,15 +8,15 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
enum class RolleE { enum class RolleE {
ADMIN, // System administrator ADMIN, // System administrator
VEREINS_ADMIN, // Club administrator VEREINS_ADMIN, // Club administrator
FUNKTIONAER, // Official/functionary FUNKTIONAER, // Official/functionary
REITER, // Rider REITER, // Rider
TRAINER, // Trainer TRAINER, // Trainer
RICHTER, // Judge RICHTER, // Judge
TIERARZT, // Veterinarian TIERARZT, // Veterinarian
ZUSCHAUER, // Spectator ZUSCHAUER, // Spectator
GAST // Guest GAST // Guest
} }
/** /**
@@ -25,27 +25,27 @@ enum class RolleE {
*/ */
@Serializable @Serializable
enum class BerechtigungE { enum class BerechtigungE {
// Person management // Person management
PERSON_READ, PERSON_READ,
PERSON_CREATE, PERSON_CREATE,
PERSON_UPDATE, PERSON_UPDATE,
PERSON_DELETE, PERSON_DELETE,
// Club management // Club management
VEREIN_READ, VEREIN_READ,
VEREIN_CREATE, VEREIN_CREATE,
VEREIN_UPDATE, VEREIN_UPDATE,
VEREIN_DELETE, VEREIN_DELETE,
// Event management // Event management
VERANSTALTUNG_READ, VERANSTALTUNG_READ,
VERANSTALTUNG_CREATE, VERANSTALTUNG_CREATE,
VERANSTALTUNG_UPDATE, VERANSTALTUNG_UPDATE,
VERANSTALTUNG_DELETE, VERANSTALTUNG_DELETE,
// Horse management // Horse management
PFERD_READ, PFERD_READ,
PFERD_CREATE, PFERD_CREATE,
PFERD_UPDATE, PFERD_UPDATE,
PFERD_DELETE PFERD_DELETE
} }
@@ -1,93 +0,0 @@
package at.mocode.clients.pingfeature.model
/**
* Phase 1 Validierung für Reitsport-Authentication-Testing
* Testet alle Erfolgs-Kriterien aus der Aufgabenstellung
*/
object Phase1Validation {
/**
* Führt alle Phase 1 Validierungen durch
*/
fun validatePhase1(): String {
val results = mutableListOf<String>()
// ✅ Test 1: Anzahl Rollen (erwartet: 9)
val roleCount = ReitsportRoles.ALL_ROLES.size
results.add("✅ Rollen-Anzahl: $roleCount (erwartet: 9) - ${if (roleCount == 9) "ERFOLG" else "FEHLER"}")
// ✅ Test 2: Admin-Rolle verfügbar
val adminRole = ReitsportRoles.ADMIN
results.add("✅ Admin-Rolle: ${adminRole.displayName} - ERFOLG")
// ✅ Test 3: Alle Kategorien verfügbar
val categories = ReitsportRoles.ROLES_BY_CATEGORY.keys
results.add("✅ Kategorien: $categories - ERFOLG")
results.add(" - SYSTEM: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.SYSTEM]?.size ?: 0} Rollen")
results.add(" - OFFICIAL: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.OFFICIAL]?.size ?: 0} Rollen")
results.add(" - ACTIVE: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.ACTIVE]?.size ?: 0} Rollen")
results.add(" - PASSIVE: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.PASSIVE]?.size ?: 0} Rollen")
// ✅ Test 4: DateTime funktioniert
val timestamp = DateTimeHelper.now()
results.add("✅ DateTime funktioniert: $timestamp - ERFOLG")
// ✅ Test 5: Test-ID generiert
val testId = getTimeMillis().toString()
results.add("✅ Test-ID generiert: $testId - ERFOLG")
// ✅ Test 6: Enum-Zugriff funktioniert
results.add("✅ RolleE Enum: ${RolleE.entries.size} Einträge - ERFOLG")
results.add("✅ BerechtigungE Enum: ${BerechtigungE.entries.size} Einträge - ERFOLG")
// ✅ Test 7: Alle 9 Rollen einzeln prüfen
results.add("✅ Alle Rollen-Definitionen:")
ReitsportRoles.ALL_ROLES.forEachIndexed { index, role ->
results.add(" ${index + 1}. ${role.displayName} (${role.roleType}) - ${role.permissions.size} Berechtigungen")
}
// ✅ Test 8: Berechtigungen-Zuordnung testen
val adminPermissions = ReitsportRoles.ADMIN.permissions.size
val guestPermissions = ReitsportRoles.GAST.permissions.size
results.add("✅ Admin-Berechtigungen: $adminPermissions (max)")
results.add("✅ Gast-Berechtigungen: $guestPermissions (min)")
// ✅ Test 9: Hilfsfunktionen testen
val roleByType = ReitsportRoles.getRoleByType(RolleE.RICHTER)
results.add("✅ Rolle per Type: ${roleByType?.displayName} - ERFOLG")
val rolesWithRead = ReitsportRoles.getRolesWithPermission(BerechtigungE.PERSON_READ)
results.add("✅ Rollen mit PERSON_READ: ${rolesWithRead.size} - ERFOLG")
return results.joinToString("\n")
}
/**
* Führt Performance-Test durch
*/
fun performanceTest(): String {
val start = DateTimeHelper.now()
// Simuliere mehrere Rollen-Abfragen
repeat(100) {
ReitsportRoles.getAllRoles()
ReitsportRoles.getRoleByType(RolleE.ADMIN)
ReitsportRoles.getRolesWithPermission(BerechtigungE.PERSON_READ)
}
val end = DateTimeHelper.now()
val duration = end - start
return "✅ Performance-Test: $duration Zeiteinheiten für 300 Operationen - ERFOLG"
}
}
/**
* Hilfsfunktion für externe Zeitabfrage
*/
private fun getTimeMillis(): Long = DateTimeHelper.now()
/**
* Extension für einfacheren Zugriff
*/
private fun ReitsportRoles.getAllRoles() = ALL_ROLES
@@ -13,27 +13,27 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class ReitsportRole( data class ReitsportRole(
val roleType: RolleE, val roleType: RolleE,
val displayName: String, val displayName: String,
val description: String, val description: String,
val icon: String, val icon: String,
val permissions: List<BerechtigungE>, val permissions: List<BerechtigungE>,
val priority: Int, // Für Sortierung in UI (1 = höchste Priorität) val priority: Int, // Für Sortierung in UI (1 = höchste Priorität)
val category: RoleCategory val category: RoleCategory
) { ) {
/** /**
* Hilfsfunktion: Prüft, ob diese Rolle eine bestimmte Berechtigung hat * Hilfsfunktion: Prüft, ob diese Rolle eine bestimmte Berechtigung hat
*/ */
fun hasPermission(permission: BerechtigungE): Boolean { fun hasPermission(permission: BerechtigungE): Boolean {
return permissions.contains(permission) return permissions.contains(permission)
} }
/** /**
* Hilfsfunktion: Gibt alle fehlenden Berechtigungen für eine Liste zurück * Hilfsfunktion: Gibt alle fehlenden Berechtigungen für eine Liste zurück
*/ */
fun getMissingPermissions(requiredPermissions: List<BerechtigungE>): List<BerechtigungE> { fun getMissingPermissions(requiredPermissions: List<BerechtigungE>): List<BerechtigungE> {
return requiredPermissions.filter { !permissions.contains(it) } return requiredPermissions.filter { !permissions.contains(it) }
} }
} }
/** /**
@@ -41,10 +41,10 @@ data class ReitsportRole(
*/ */
@Serializable @Serializable
enum class RoleCategory(val displayName: String, val color: String) { enum class RoleCategory(val displayName: String, val color: String) {
SYSTEM("System-Verwaltung", "#FF5722"), // Rot SYSTEM("System-Verwaltung", "#FF5722"), // Rot
OFFICIAL("Offizielle Funktionen", "#3F51B5"), // Indigo OFFICIAL("Offizielle Funktionen", "#3F51B5"), // Indigo
ACTIVE("Aktive Teilnahme", "#4CAF50"), // Grün ACTIVE("Aktive Teilnahme", "#4CAF50"), // Grün
PASSIVE("Information & Zugang", "#9E9E9E") // Grau PASSIVE("Information & Zugang", "#9E9E9E") // Grau
} }
/** /**
@@ -52,17 +52,17 @@ enum class RoleCategory(val displayName: String, val color: String) {
*/ */
@Serializable @Serializable
data class AuthTestScenario( data class AuthTestScenario(
val id: String, val id: String,
val name: String, val name: String,
val businessProcess: String, val businessProcess: String,
val description: String, val description: String,
val expectedBehavior: String, val expectedBehavior: String,
val requiredRole: RolleE, val requiredRole: RolleE,
val requiredPermissions: List<BerechtigungE>, val requiredPermissions: List<BerechtigungE>,
val testEndpoint: String, val testEndpoint: String,
val testMethod: String = "GET", val testMethod: String = "GET",
val priority: TestPriority = TestPriority.NORMAL, val priority: TestPriority = TestPriority.NORMAL,
val category: ScenarioCategory val category: ScenarioCategory
) )
/** /**
@@ -70,37 +70,37 @@ data class AuthTestScenario(
*/ */
@Serializable @Serializable
enum class ScenarioCategory(val displayName: String, val icon: String) { enum class ScenarioCategory(val displayName: String, val icon: String) {
// Kern-Geschäftsprozesse // Kern-Geschäftsprozesse
VERANSTALTUNG_SETUP("Veranstaltungs-Einrichtung", "🏟️"), VERANSTALTUNG_SETUP("Veranstaltungs-Einrichtung", "🏟️"),
TURNIER_MANAGEMENT("Turnier-Verwaltung", "🎪"), TURNIER_MANAGEMENT("Turnier-Verwaltung", "🎪"),
BEWERB_KONFIGURATION("Bewerb-Konfiguration", "🏇"), BEWERB_KONFIGURATION("Bewerb-Konfiguration", "🏇"),
// Finanzen // Finanzen
KASSABUCH("Kassabuch-Führung", "💰"), KASSABUCH("Kassabuch-Führung", "💰"),
ABRECHNUNG("Turnier-Abrechnung", "🧾"), ABRECHNUNG("Turnier-Abrechnung", "🧾"),
// Nennsystem // Nennsystem
NENNUNG_WEBFORMULAR("Nenn-Web-Formular", "📝"), NENNUNG_WEBFORMULAR("Nenn-Web-Formular", "📝"),
NENNUNG_MOBILE("Mobile Nennung", "📱"), NENNUNG_MOBILE("Mobile Nennung", "📱"),
NENNTAUSCH("Nenntausch-System", "🔄"), NENNTAUSCH("Nenntausch-System", "🔄"),
// Startlisten & Zeitplan // Startlisten & Zeitplan
ZEITPLAN_ERSTELLUNG("Zeitplan-Erstellung", "⏰"), ZEITPLAN_ERSTELLUNG("Zeitplan-Erstellung", "⏰"),
STARTERLISTE_FLEXIBEL("Flexible Starterlisten", "📋"), STARTERLISTE_FLEXIBEL("Flexible Starterlisten", "📋"),
RICHTER_VALIDATION("Richter-Lizenz-Validierung", "⚖️"), RICHTER_VALIDATION("Richter-Lizenz-Validierung", "⚖️"),
// Ergebnisse // Ergebnisse
ERGEBNIS_DRESSUR("Ergebnis-Erfassung Dressur", "🎭"), ERGEBNIS_DRESSUR("Ergebnis-Erfassung Dressur", "🎭"),
ERGEBNIS_SPRINGEN("Ergebnis-Erfassung Springen", "🚀"), ERGEBNIS_SPRINGEN("Ergebnis-Erfassung Springen", "🚀"),
ERGEBNIS_VIELSEITIGKEIT("Ergebnis-Erfassung Vielseitigkeit", "🎯"), ERGEBNIS_VIELSEITIGKEIT("Ergebnis-Erfassung Vielseitigkeit", "🎯"),
// OEPS Integration // OEPS Integration
OEPS_SYNC("OEPS-Synchronisation", "🔗"), OEPS_SYNC("OEPS-Synchronisation", "🔗"),
TURNIER_NUMMER("Turnier-Nummer-Verwaltung", "🔢"), TURNIER_NUMMER("Turnier-Nummer-Verwaltung", "🔢"),
// System // System
SYSTEM_ADMIN("System-Administration", "🔧"), SYSTEM_ADMIN("System-Administration", "🔧"),
BENUTZER_VERWALTUNG("Benutzer-Verwaltung", "👥") BENUTZER_VERWALTUNG("Benutzer-Verwaltung", "👥")
} }
/** /**
@@ -108,29 +108,29 @@ enum class ScenarioCategory(val displayName: String, val icon: String) {
*/ */
@Serializable @Serializable
data class ComplexAuthTestScenario( data class ComplexAuthTestScenario(
val id: String, val id: String,
val name: String, val name: String,
val businessProcess: String, val businessProcess: String,
val description: String, val description: String,
val subProcesses: List<String>, // Multi-Step-Prozesse val subProcesses: List<String>, // Multi-Step-Prozesse
val requiredRole: RolleE, val requiredRole: RolleE,
val requiredPermissions: List<BerechtigungE>, val requiredPermissions: List<BerechtigungE>,
val testEndpoints: List<TestEndpoint>, // Mehrere API-Calls val testEndpoints: List<TestEndpoint>, // Mehrere API-Calls
val mockData: Map<String, String> = emptyMap(), val mockData: Map<String, String> = emptyMap(),
val expectedOutcome: String, val expectedOutcome: String,
val priority: TestPriority = TestPriority.NORMAL, val priority: TestPriority = TestPriority.NORMAL,
val category: ScenarioCategory, val category: ScenarioCategory,
val oepsIntegrationRequired: Boolean = false val oepsIntegrationRequired: Boolean = false
) )
@Serializable @Serializable
data class TestEndpoint( data class TestEndpoint(
val name: String, val name: String,
val url: String, val url: String,
val method: String = "GET", val method: String = "GET",
val payload: String? = null, val payload: String? = null,
val expectedResponseCode: Int = 200, val expectedResponseCode: Int = 200,
val description: String val description: String
) )
/** /**
@@ -138,10 +138,10 @@ data class TestEndpoint(
*/ */
@Serializable @Serializable
enum class TestPriority(val displayName: String, val level: Int) { enum class TestPriority(val displayName: String, val level: Int) {
CRITICAL("Kritisch", 1), CRITICAL("Kritisch", 1),
HIGH("Hoch", 2), HIGH("Hoch", 2),
NORMAL("Normal", 3), NORMAL("Normal", 3),
LOW("Niedrig", 4) LOW("Niedrig", 4)
} }
/** /**
@@ -149,29 +149,29 @@ enum class TestPriority(val displayName: String, val level: Int) {
*/ */
@Serializable @Serializable
data class ApiTestResult( data class ApiTestResult(
val scenarioId: String, val scenarioId: String,
val scenarioName: String, val scenarioName: String,
val endpoint: String, val endpoint: String,
val method: String, val method: String,
val expectedResult: String, val expectedResult: String,
val actualResult: String, val actualResult: String,
val success: Boolean, val success: Boolean,
val responseCode: Int? = null, val responseCode: Int? = null,
val duration: Long, // in Millisekunden val duration: Long, // in Millisekunden
val timestamp: Long = getTimeMillis(), val timestamp: Long = getTimeMillis(),
val token: String? = null, // Gekürzte Token-Info für Debugging val token: String? = null, // Gekürzte Token-Info für Debugging
val errorMessage: String? = null, val errorMessage: String? = null,
val responseData: String? = null val responseData: String? = null
) { ) {
/** /**
* Hilfsfunktion: Formatiert die Dauer für UI-Anzeige * Hilfsfunktion: Formatiert die Dauer für UI-Anzeige
*/ */
fun formatDuration(): String = "${duration}ms" fun formatDuration(): String = "${duration}ms"
/** /**
* Hilfsfunktion: Status-Icon für UI * Hilfsfunktion: Status-Icon für UI
*/ */
fun getStatusIcon(): String = if (success) "✅" else "❌" fun getStatusIcon(): String = if (success) "✅" else "❌"
} }
/** /**
@@ -179,33 +179,33 @@ data class ApiTestResult(
*/ */
@Serializable @Serializable
data class ReitsportTestResult( data class ReitsportTestResult(
val testId: String = getTimeMillis().toString(), val testId: String = getTimeMillis().toString(),
val role: ReitsportRole, val role: ReitsportRole,
val scenarios: List<AuthTestScenario>, val scenarios: List<AuthTestScenario>,
val apiResults: List<ApiTestResult>, val apiResults: List<ApiTestResult>,
val startTime: Long, val startTime: Long,
val endTime: Long? = null, val endTime: Long? = null,
val overallSuccess: Boolean = false, val overallSuccess: Boolean = false,
val summary: TestSummary? = null val summary: TestSummary? = null
) { ) {
/** /**
* Berechnet die Gesamtdauer des Tests * Berechnet die Gesamtdauer des Tests
*/ */
fun getTotalDuration(): Long = (endTime ?: getTimeMillis()) - startTime fun getTotalDuration(): Long = (endTime ?: getTimeMillis()) - startTime
/** /**
* Berechnet Erfolgsrate in Prozent * Berechnet Erfolgsrate in Prozent
*/ */
fun getSuccessRate(): Double { fun getSuccessRate(): Double {
if (apiResults.isEmpty()) return 0.0 if (apiResults.isEmpty()) return 0.0
val successful = apiResults.count { it.success } val successful = apiResults.count { it.success }
return (successful.toDouble() / apiResults.size) * 100 return (successful.toDouble() / apiResults.size) * 100
} }
/** /**
* Gibt alle fehlgeschlagenen Tests zurück * Gibt alle fehlgeschlagenen Tests zurück
*/ */
fun getFailedTests(): List<ApiTestResult> = apiResults.filter { !it.success } fun getFailedTests(): List<ApiTestResult> = apiResults.filter { !it.success }
} }
/** /**
@@ -213,15 +213,15 @@ data class ReitsportTestResult(
*/ */
@Serializable @Serializable
data class TestSummary( data class TestSummary(
val totalTests: Int, val totalTests: Int,
val successfulTests: Int, val successfulTests: Int,
val failedTests: Int, val failedTests: Int,
val averageDuration: Long, val averageDuration: Long,
val criticalFailures: List<String> = emptyList(), val criticalFailures: List<String> = emptyList(),
val recommendations: List<String> = emptyList() val recommendations: List<String> = emptyList()
) { ) {
val successRate: Double val successRate: Double
get() = if (totalTests > 0) (successfulTests.toDouble() / totalTests) * 100 else 0.0 get() = if (totalTests > 0) (successfulTests.toDouble() / totalTests) * 100 else 0.0
} }
/** /**
@@ -229,17 +229,17 @@ data class TestSummary(
*/ */
@Serializable @Serializable
data class TestNennung( data class TestNennung(
val reiterId: String, val reiterId: String,
val pferdId: String, val pferdId: String,
val bewerbId: String, val bewerbId: String,
val nennungsDatum: Long = getTimeMillis() val nennungsDatum: Long = getTimeMillis()
) )
@Serializable @Serializable
data class TestStartbereitschaft( data class TestStartbereitschaft(
val nennungId: String, val nennungId: String,
val confirmed: Boolean = true, val confirmed: Boolean = true,
val confirmationTime: Long = getTimeMillis() val confirmationTime: Long = getTimeMillis()
) )
/** /**
@@ -247,14 +247,14 @@ data class TestStartbereitschaft(
* Temporäre Lösung für Phase 1 mit incrementellem Counter * Temporäre Lösung für Phase 1 mit incrementellem Counter
*/ */
object DateTimeHelper { object DateTimeHelper {
private var counter = 1000000000L // Start mit einer realistischen Timestamp private var counter = 1000000000L // Start mit einer realistischen Timestamp
fun now(): Long = counter++ fun now(): Long = counter++
fun formatDateTime(timestamp: Long): String { fun formatDateTime(timestamp: Long): String {
// Einfache ISO-ähnliche Formatierung ohne kotlinx-datetime // Einfache ISO-ähnliche Formatierung ohne kotlinx-datetime
return "Timestamp: $timestamp" // Temporäre Lösung für Phase 1 return "Timestamp: $timestamp" // Temporäre Lösung für Phase 1
} }
} }
/** /**
@@ -6,215 +6,215 @@ package at.mocode.clients.pingfeature.model
*/ */
object ReitsportRoles { object ReitsportRoles {
/** /**
* System-Administrator - Vollzugriff auf alle Bounded Contexts * System-Administrator - Vollzugriff auf alle Bounded Contexts
*/ */
val ADMIN = ReitsportRole( val ADMIN = ReitsportRole(
roleType = RolleE.ADMIN, roleType = RolleE.ADMIN,
displayName = "System-Administrator", displayName = "System-Administrator",
description = "Vollzugriff auf alle Microservices und System-Konfiguration", description = "Vollzugriff auf alle Microservices und System-Konfiguration",
icon = "🔧", icon = "🔧",
permissions = BerechtigungE.entries, // Alle verfügbaren Berechtigungen permissions = BerechtigungE.entries, // Alle verfügbaren Berechtigungen
priority = 1, priority = 1,
category = RoleCategory.SYSTEM category = RoleCategory.SYSTEM
) )
/** /**
* Vereins-Administrator - Vereins-Bounded-Context * Vereins-Administrator - Vereins-Bounded-Context
*/ */
val VEREINS_ADMIN = ReitsportRole( val VEREINS_ADMIN = ReitsportRole(
roleType = RolleE.VEREINS_ADMIN, roleType = RolleE.VEREINS_ADMIN,
displayName = "Vereins-Administrator", displayName = "Vereins-Administrator",
description = "Vereinsverwaltung und Mitglieder-Management", description = "Vereinsverwaltung und Mitglieder-Management",
icon = "🏛️", icon = "🏛️",
permissions = listOf( permissions = listOf(
// Personen (Mitglieder) // Personen (Mitglieder)
BerechtigungE.PERSON_READ, BerechtigungE.PERSON_READ,
BerechtigungE.PERSON_CREATE, BerechtigungE.PERSON_CREATE,
BerechtigungE.PERSON_UPDATE, BerechtigungE.PERSON_UPDATE,
BerechtigungE.PERSON_DELETE, BerechtigungE.PERSON_DELETE,
// Verein // Verein
BerechtigungE.VEREIN_READ, BerechtigungE.VEREIN_READ,
BerechtigungE.VEREIN_UPDATE, BerechtigungE.VEREIN_UPDATE,
// Veranstaltungen organisieren // Veranstaltungen organisieren
BerechtigungE.VERANSTALTUNG_READ, BerechtigungE.VERANSTALTUNG_READ,
BerechtigungE.VERANSTALTUNG_CREATE, BerechtigungE.VERANSTALTUNG_CREATE,
BerechtigungE.VERANSTALTUNG_UPDATE, BerechtigungE.VERANSTALTUNG_UPDATE,
// Pferde (für Vereinsmitglieder) // Pferde (für Vereinsmitglieder)
BerechtigungE.PFERD_READ BerechtigungE.PFERD_READ
), ),
priority = 2, priority = 2,
category = RoleCategory.SYSTEM category = RoleCategory.SYSTEM
) )
/** /**
* Funktionär - Event-Management-Bounded-Context * Funktionär - Event-Management-Bounded-Context
*/ */
val FUNKTIONAER = ReitsportRole( val FUNKTIONAER = ReitsportRole(
roleType = RolleE.FUNKTIONAER, roleType = RolleE.FUNKTIONAER,
displayName = "Funktionär (Meldestelle)", displayName = "Funktionär (Meldestelle)",
description = "Turnierorganisation: Nennungen, Starterlisten, Meldestellen-Workflows", description = "Turnierorganisation: Nennungen, Starterlisten, Meldestellen-Workflows",
icon = "⚖️", icon = "⚖️",
permissions = listOf( permissions = listOf(
// Lesen aller relevanten Daten // Lesen aller relevanten Daten
BerechtigungE.PERSON_READ, BerechtigungE.PERSON_READ,
BerechtigungE.PFERD_READ, BerechtigungE.PFERD_READ,
BerechtigungE.VERANSTALTUNG_READ, BerechtigungE.VERANSTALTUNG_READ,
BerechtigungE.VERANSTALTUNG_UPDATE, // Turnier-Management BerechtigungE.VERANSTALTUNG_UPDATE, // Turnier-Management
// Erweiterte Rechte in Veranstaltungs-Context // Erweiterte Rechte in Veranstaltungs-Context
// (Hier werden später Nennung-, Startlisten-Berechtigungen hinzugefügt) // (Hier werden später Nennung-, Startlisten-Berechtigungen hinzugefügt)
), ),
priority = 3, priority = 3,
category = RoleCategory.OFFICIAL category = RoleCategory.OFFICIAL
) )
/** /**
* Richter - Spezialisierte Bewertungs-Rolle * Richter - Spezialisierte Bewertungs-Rolle
*/ */
val RICHTER = ReitsportRole( val RICHTER = ReitsportRole(
roleType = RolleE.RICHTER, roleType = RolleE.RICHTER,
displayName = "Richter", displayName = "Richter",
description = "Prüfungs-Bewertung und Ergebnis-Eingabe (ReadOnly-Zugriff auf Stammdaten)", description = "Prüfungs-Bewertung und Ergebnis-Eingabe (ReadOnly-Zugriff auf Stammdaten)",
icon = "⚖️", icon = "⚖️",
permissions = listOf( permissions = listOf(
// Nur Lese-Zugriff auf relevante Daten // Nur Lese-Zugriff auf relevante Daten
BerechtigungE.PERSON_READ, // Starter-Info BerechtigungE.PERSON_READ, // Starter-Info
BerechtigungE.PFERD_READ, // Pferde-Info BerechtigungE.PFERD_READ, // Pferde-Info
BerechtigungE.VERANSTALTUNG_READ // Prüfungs-Details BerechtigungE.VERANSTALTUNG_READ // Prüfungs-Details
// Ergebnis-Eingabe wird später als eigener Bounded Context hinzugefügt // Ergebnis-Eingabe wird später als eigener Bounded Context hinzugefügt
), ),
priority = 4, priority = 4,
category = RoleCategory.OFFICIAL category = RoleCategory.OFFICIAL
) )
/** /**
* Tierarzt - Veterinär-Bounded-Context * Tierarzt - Veterinär-Bounded-Context
*/ */
val TIERARZT = ReitsportRole( val TIERARZT = ReitsportRole(
roleType = RolleE.TIERARZT, roleType = RolleE.TIERARZT,
displayName = "Tierarzt", displayName = "Tierarzt",
description = "Veterinärkontrollen und Pferde-Gesundheits-Management", description = "Veterinärkontrollen und Pferde-Gesundheits-Management",
icon = "🩺", icon = "🩺",
permissions = listOf( permissions = listOf(
BerechtigungE.PFERD_READ, BerechtigungE.PFERD_READ,
BerechtigungE.PFERD_UPDATE, // Gesundheitsdaten, Vet-Checks BerechtigungE.PFERD_UPDATE, // Gesundheitsdaten, Vet-Checks
BerechtigungE.PERSON_READ, // Besitzer-Kontakt BerechtigungE.PERSON_READ, // Besitzer-Kontakt
BerechtigungE.VERANSTALTUNG_READ // Turnier-Context für Kontrollen BerechtigungE.VERANSTALTUNG_READ // Turnier-Context für Kontrollen
), ),
priority = 5, priority = 5,
category = RoleCategory.OFFICIAL category = RoleCategory.OFFICIAL
) )
/** /**
* Trainer - Training-Bounded-Context (zukünftig) * Trainer - Training-Bounded-Context (zukünftig)
*/ */
val TRAINER = ReitsportRole( val TRAINER = ReitsportRole(
roleType = RolleE.TRAINER, roleType = RolleE.TRAINER,
displayName = "Trainer", displayName = "Trainer",
description = "Schützlings-Betreuung und Training-Management", description = "Schützlings-Betreuung und Training-Management",
icon = "🏃‍♂️", icon = "🏃‍♂️",
permissions = listOf( permissions = listOf(
BerechtigungE.PERSON_READ, // Schützlinge BerechtigungE.PERSON_READ, // Schützlinge
BerechtigungE.PFERD_READ, // Trainingspferde BerechtigungE.PFERD_READ, // Trainingspferde
BerechtigungE.VERANSTALTUNG_READ // Turnier-Planung für Schützlinge BerechtigungE.VERANSTALTUNG_READ // Turnier-Planung für Schützlinge
// Training-spezifische Berechtigungen kommen später // Training-spezifische Berechtigungen kommen später
), ),
priority = 6, priority = 6,
category = RoleCategory.ACTIVE category = RoleCategory.ACTIVE
) )
/** /**
* Reiter - Persönlicher Bounded Context * Reiter - Persönlicher Bounded Context
*/ */
val REITER = ReitsportRole( val REITER = ReitsportRole(
roleType = RolleE.REITER, roleType = RolleE.REITER,
displayName = "Reiter", displayName = "Reiter",
description = "Persönliche Daten, eigene Pferde und Turnier-Teilnahme", description = "Persönliche Daten, eigene Pferde und Turnier-Teilnahme",
icon = "🐎", icon = "🐎",
permissions = listOf( permissions = listOf(
BerechtigungE.PERSON_READ, // Nur eigene Daten BerechtigungE.PERSON_READ, // Nur eigene Daten
BerechtigungE.PFERD_READ, // Nur eigene Pferde BerechtigungE.PFERD_READ, // Nur eigene Pferde
BerechtigungE.VERANSTALTUNG_READ // Öffentliche Turnier-Infos BerechtigungE.VERANSTALTUNG_READ // Öffentliche Turnier-Infos
// Eigene Daten ändern: Später als PERSON_UPDATE_OWN, PFERD_UPDATE_OWN // Eigene Daten ändern: Später als PERSON_UPDATE_OWN, PFERD_UPDATE_OWN
), ),
priority = 7, priority = 7,
category = RoleCategory.ACTIVE category = RoleCategory.ACTIVE
) )
/** /**
* Zuschauer - Public-Read-Only Bounded Context * Zuschauer - Public-Read-Only Bounded Context
*/ */
val ZUSCHAUER = ReitsportRole( val ZUSCHAUER = ReitsportRole(
roleType = RolleE.ZUSCHAUER, roleType = RolleE.ZUSCHAUER,
displayName = "Zuschauer", displayName = "Zuschauer",
description = "Öffentliche Informationen: Starterlisten, Ergebnisse, Zeitpläne", description = "Öffentliche Informationen: Starterlisten, Ergebnisse, Zeitpläne",
icon = "👁️", icon = "👁️",
permissions = listOf( permissions = listOf(
BerechtigungE.VERANSTALTUNG_READ // Nur öffentliche Turnier-Daten BerechtigungE.VERANSTALTUNG_READ // Nur öffentliche Turnier-Daten
// Später: STARTERLISTE_READ_PUBLIC, ERGEBNIS_READ_PUBLIC // Später: STARTERLISTE_READ_PUBLIC, ERGEBNIS_READ_PUBLIC
), ),
priority = 8, priority = 8,
category = RoleCategory.PASSIVE category = RoleCategory.PASSIVE
) )
/** /**
* Gast - Keine Authentifizierung erforderlich * Gast - Keine Authentifizierung erforderlich
*/ */
val GAST = ReitsportRole( val GAST = ReitsportRole(
roleType = RolleE.GAST, roleType = RolleE.GAST,
displayName = "Gast", displayName = "Gast",
description = "Öffentliche Basis-Informationen ohne Registrierung", description = "Öffentliche Basis-Informationen ohne Registrierung",
icon = "🔓", icon = "🔓",
permissions = emptyList(), // Nur völlig öffentliche Endpunkte permissions = emptyList(), // Nur völlig öffentliche Endpunkte
priority = 9, priority = 9,
category = RoleCategory.PASSIVE category = RoleCategory.PASSIVE
) )
/** /**
* Alle definierten Rollen in organisatorischer Reihenfolge * Alle definierten Rollen in organisatorischer Reihenfolge
*/ */
val ALL_ROLES = listOf( val ALL_ROLES = listOf(
ADMIN, ADMIN,
VEREINS_ADMIN, VEREINS_ADMIN,
FUNKTIONAER, FUNKTIONAER,
RICHTER, RICHTER,
TIERARZT, TIERARZT,
TRAINER, TRAINER,
REITER, REITER,
ZUSCHAUER, ZUSCHAUER,
GAST GAST
) )
/** /**
* Rollen nach Bounded Context / Microservice gruppiert * Rollen nach Bounded Context / Microservice gruppiert
*/ */
val ROLES_BY_BOUNDED_CONTEXT = mapOf( val ROLES_BY_BOUNDED_CONTEXT = mapOf(
"System Management" to listOf(ADMIN), "System Management" to listOf(ADMIN),
"Vereins-Service" to listOf(VEREINS_ADMIN), "Vereins-Service" to listOf(VEREINS_ADMIN),
"Event-Service" to listOf(FUNKTIONAER), "Event-Service" to listOf(FUNKTIONAER),
"Bewertungs-Service" to listOf(RICHTER), "Bewertungs-Service" to listOf(RICHTER),
"Vet-Service" to listOf(TIERARZT), "Vet-Service" to listOf(TIERARZT),
"Training-Service" to listOf(TRAINER), "Training-Service" to listOf(TRAINER),
"Member-Service" to listOf(REITER), "Member-Service" to listOf(REITER),
"Public-Service" to listOf(ZUSCHAUER, GAST) "Public-Service" to listOf(ZUSCHAUER, GAST)
) )
/** /**
* Rollen nach UI-Kategorie (für Ping-Dashboard) * Rollen nach UI-Kategorie (für Ping-Dashboard)
*/ */
val ROLES_BY_CATEGORY = ALL_ROLES.groupBy { it.category } val ROLES_BY_CATEGORY = ALL_ROLES.groupBy { it.category }
/** /**
* Hilfsfunktion: Rolle nach RolleE-Typ finden * Hilfsfunktion: Rolle nach RolleE-Typ finden
*/ */
fun getRoleByType(roleType: RolleE): ReitsportRole? { fun getRoleByType(roleType: RolleE): ReitsportRole? {
return ALL_ROLES.find { it.roleType == roleType } return ALL_ROLES.find { it.roleType == roleType }
} }
/** /**
* Hilfsfunktion: Alle Rollen mit einer bestimmten Berechtigung * Hilfsfunktion: Alle Rollen mit einer bestimmten Berechtigung
*/ */
fun getRolesWithPermission(permission: BerechtigungE): List<ReitsportRole> { fun getRolesWithPermission(permission: BerechtigungE): List<ReitsportRole> {
return ALL_ROLES.filter { it.hasPermission(permission) } return ALL_ROLES.filter { it.hasPermission(permission) }
} }
} }
@@ -12,190 +12,190 @@ import kotlin.test.assertEquals
class PingApiClientTest { class PingApiClientTest {
private fun createMockApiClient(mockEngine: MockEngine): PingApiClient { private fun createMockApiClient(mockEngine: MockEngine): PingApiClient {
return PingApiClient("http://localhost:8081") return PingApiClient("http://localhost:8081")
}
@Test
fun `simplePing should return correct response`() = runTest {
// Given
val expectedResponse = PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service"
)
val mockEngine = MockEngine { request ->
assertEquals("http://localhost:8081/api/ping/simple", request.url.toString())
assertEquals(HttpMethod.Get, request.method)
respond(
content = Json.encodeToString(PingResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
} }
@Test // When
fun `simplePing should return correct response`() = runTest { val apiClient = PingApiClient("http://localhost:8081")
// Given // Note: This is a limitation - we can't easily inject the mock engine
val expectedResponse = PingResponse( // This test demonstrates the structure but would need refactoring of PingApiClient
status = "OK", // to accept HttpClient as dependency for full testability
timestamp = "2025-09-27T21:27:00Z", }
service = "ping-service"
)
val mockEngine = MockEngine { request -> @Test
assertEquals("http://localhost:8081/api/ping/simple", request.url.toString()) fun `enhancedPing should include simulate parameter`() = runTest {
assertEquals(HttpMethod.Get, request.method) // Given
val expectedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
respond( val mockEngine = MockEngine { request ->
content = Json.encodeToString(PingResponse.serializer(), expectedResponse), assertEquals("http://localhost:8081/api/ping/enhanced", request.url.encodedPath)
status = HttpStatusCode.OK, assertEquals("true", request.url.parameters["simulate"])
headers = headersOf(HttpHeaders.ContentType, "application/json") assertEquals(HttpMethod.Get, request.method)
)
}
// When respond(
val apiClient = PingApiClient("http://localhost:8081") content = Json.encodeToString(EnhancedPingResponse.serializer(), expectedResponse),
// Note: This is a limitation - we can't easily inject the mock engine status = HttpStatusCode.OK,
// This test demonstrates the structure but would need refactoring of PingApiClient headers = headersOf(HttpHeaders.ContentType, "application/json")
// to accept HttpClient as dependency for full testability )
} }
@Test // When - This test shows the intended structure
fun `enhancedPing should include simulate parameter`() = runTest { // val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// Given // val response = apiClient.enhancedPing(simulate = true)
val expectedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
val mockEngine = MockEngine { request -> // Then
assertEquals("http://localhost:8081/api/ping/enhanced", request.url.encodedPath) // assertEquals(expectedResponse, response)
assertEquals("true", request.url.parameters["simulate"]) }
assertEquals(HttpMethod.Get, request.method)
respond( @Test
content = Json.encodeToString(EnhancedPingResponse.serializer(), expectedResponse), fun `healthCheck should return health response`() = runTest {
status = HttpStatusCode.OK, // Given
headers = headersOf(HttpHeaders.ContentType, "application/json") val expectedResponse = HealthResponse(
) status = "UP",
} timestamp = "2025-09-27T21:27:00Z",
service = "ping-service",
healthy = true
)
// When - This test shows the intended structure val mockEngine = MockEngine { request ->
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine)) assertEquals("http://localhost:8081/api/ping/health", request.url.toString())
// val response = apiClient.enhancedPing(simulate = true) assertEquals(HttpMethod.Get, request.method)
// Then respond(
// assertEquals(expectedResponse, response) content = Json.encodeToString(HealthResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
} }
@Test // When - Test structure demonstration
fun `healthCheck should return health response`() = runTest { // val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// Given // val response = apiClient.healthCheck()
val expectedResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service",
healthy = true
)
val mockEngine = MockEngine { request -> // Then
assertEquals("http://localhost:8081/api/ping/health", request.url.toString()) // assertEquals(expectedResponse, response)
assertEquals(HttpMethod.Get, request.method) }
respond( @Test
content = Json.encodeToString(HealthResponse.serializer(), expectedResponse), fun `API client should handle HTTP errors correctly`() = runTest {
status = HttpStatusCode.OK, val mockEngine = MockEngine { request ->
headers = headersOf(HttpHeaders.ContentType, "application/json") respond(
) content = """{"error": "Internal Server Error"}""",
} status = HttpStatusCode.InternalServerError,
headers = headersOf(HttpHeaders.ContentType, "application/json")
// When - Test structure demonstration )
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// val response = apiClient.healthCheck()
// Then
// assertEquals(expectedResponse, response)
} }
@Test // Test structure for error handling
fun `API client should handle HTTP errors correctly`() = runTest { // val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
val mockEngine = MockEngine { request -> // assertFailsWith<Exception> {
respond( // apiClient.simplePing()
content = """{"error": "Internal Server Error"}""", // }
status = HttpStatusCode.InternalServerError, }
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// Test structure for error handling @Test
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine)) fun `API client should handle network errors`() = runTest {
// assertFailsWith<Exception> { val mockEngine = MockEngine { request ->
// apiClient.simplePing() throw Exception("Network unreachable")
// }
} }
@Test // Test structure for network error handling
fun `API client should handle network errors`() = runTest { // val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
val mockEngine = MockEngine { request -> // assertFailsWith<Exception> {
throw Exception("Network unreachable") // apiClient.simplePing()
} // }
}
// Test structure for network error handling @Test
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine)) fun `JSON serialization should work correctly`() {
// assertFailsWith<Exception> { // Given
// apiClient.simplePing() val pingResponse = PingResponse(
// } status = "OK",
} timestamp = "2025-09-27T21:27:00Z",
service = "test-service"
)
@Test // When
fun `JSON serialization should work correctly`() { val json = Json.encodeToString(PingResponse.serializer(), pingResponse)
// Given val deserializedResponse = Json.decodeFromString(PingResponse.serializer(), json)
val pingResponse = PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service"
)
// When // Then
val json = Json.encodeToString(PingResponse.serializer(), pingResponse) assertEquals(pingResponse, deserializedResponse)
val deserializedResponse = Json.decodeFromString(PingResponse.serializer(), json) }
// Then @Test
assertEquals(pingResponse, deserializedResponse) fun `Enhanced ping response serialization should work correctly`() {
} // Given
val enhancedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
circuitBreakerState = "CLOSED",
responseTime = 123L
)
@Test // When
fun `Enhanced ping response serialization should work correctly`() { val json = Json.encodeToString(EnhancedPingResponse.serializer(), enhancedResponse)
// Given val deserializedResponse = Json.decodeFromString(EnhancedPingResponse.serializer(), json)
val enhancedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
circuitBreakerState = "CLOSED",
responseTime = 123L
)
// When // Then
val json = Json.encodeToString(EnhancedPingResponse.serializer(), enhancedResponse) assertEquals(enhancedResponse, deserializedResponse)
val deserializedResponse = Json.decodeFromString(EnhancedPingResponse.serializer(), json) }
// Then @Test
assertEquals(enhancedResponse, deserializedResponse) fun `Health response serialization should work correctly`() {
} // Given
val healthResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
healthy = true
)
@Test // When
fun `Health response serialization should work correctly`() { val json = Json.encodeToString(HealthResponse.serializer(), healthResponse)
// Given val deserializedResponse = Json.decodeFromString(HealthResponse.serializer(), json)
val healthResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
healthy = true
)
// When // Then
val json = Json.encodeToString(HealthResponse.serializer(), healthResponse) assertEquals(healthResponse, deserializedResponse)
val deserializedResponse = Json.decodeFromString(HealthResponse.serializer(), json) }
// Then // Note: The HTTP request tests above demonstrate the test structure but are commented out
assertEquals(healthResponse, deserializedResponse) // because the current PingApiClient implementation doesn't support dependency injection
} // of HttpClient. To make these tests fully functional, PingApiClient would need to be
// refactored to accept HttpClient as a constructor parameter:
// Note: The HTTP request tests above demonstrate the test structure but are commented out //
// because the current PingApiClient implementation doesn't support dependency injection // class PingApiClient(
// of HttpClient. To make these tests fully functional, PingApiClient would need to be // private val baseUrl: String = "http://localhost:8081",
// refactored to accept HttpClient as a constructor parameter: // private val httpClient: HttpClient = HttpClient { ... }
// // )
// class PingApiClient( //
// private val baseUrl: String = "http://localhost:8081", // This would enable full HTTP mocking and testing capabilities.
// private val httpClient: HttpClient = HttpClient { ... }
// )
//
// This would enable full HTTP mocking and testing capabilities.
} }
@@ -10,253 +10,253 @@ import kotlin.test.*
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class PingViewModelTest { class PingViewModelTest {
private lateinit var viewModel: PingViewModel private lateinit var viewModel: PingViewModel
private lateinit var testApiClient: TestPingApiClient private lateinit var testApiClient: TestPingApiClient
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
@BeforeTest @BeforeTest
fun setup() { fun setup() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
testApiClient = TestPingApiClient() testApiClient = TestPingApiClient()
viewModel = PingViewModel(testApiClient) viewModel = PingViewModel(testApiClient)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
testApiClient.reset()
}
@Test
fun `initial state should be empty`() {
// Given & When - initial state
val initialState = viewModel.uiState
// Then
assertFalse(initialState.isLoading)
assertNull(initialState.simplePingResponse)
assertNull(initialState.enhancedPingResponse)
assertNull(initialState.healthResponse)
assertNull(initialState.errorMessage)
}
@Test
fun `performSimplePing should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service"
)
testApiClient.simplePingResponse = expectedResponse
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.simplePingResponse)
assertNull(finalState.errorMessage)
assertTrue(testApiClient.simplePingCalled)
}
@Test
fun `performSimplePing should set loading state during execution`() = runTest(testDispatcher) {
// Given
testApiClient.simulateDelay = true
testApiClient.delayMs = 100
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceTimeBy(1) // Allow the coroutine to start
// Then - should be loading during execution
assertTrue(viewModel.uiState.isLoading)
assertNull(viewModel.uiState.errorMessage)
// When - complete the operation
testDispatcher.scheduler.advanceUntilIdle()
// Then - should not be loading anymore
assertFalse(viewModel.uiState.isLoading)
}
@Test
fun `performSimplePing should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Network error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.simplePingResponse)
assertEquals("Simple ping failed: $errorMessage", finalState.errorMessage)
assertTrue(testApiClient.simplePingCalled)
}
@Test
fun `performEnhancedPing should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
testApiClient.enhancedPingResponse = expectedResponse
// When
viewModel.performEnhancedPing(simulate = false)
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.enhancedPingResponse)
assertNull(finalState.errorMessage)
assertEquals(false, testApiClient.enhancedPingCalledWith)
}
@Test
fun `performEnhancedPing should handle simulate parameter correctly`() = runTest(testDispatcher) {
// When
viewModel.performEnhancedPing(simulate = true)
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals(true, testApiClient.enhancedPingCalledWith)
}
@Test
fun `performEnhancedPing should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Enhanced ping error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performEnhancedPing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.enhancedPingResponse)
assertEquals("Enhanced ping failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `performHealthCheck should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
healthy = true
)
testApiClient.healthResponse = expectedResponse
// When
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.healthResponse)
assertNull(finalState.errorMessage)
assertTrue(testApiClient.healthCheckCalled)
}
@Test
fun `performHealthCheck should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Health check error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.healthResponse)
assertEquals("Health check failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `clearError should remove error message from state`() {
// Given - set up an error state by simulating an error
testApiClient.shouldThrowException = true
runTest(testDispatcher) {
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
} }
@AfterTest // Verify error is present
fun tearDown() { assertNotNull(viewModel.uiState.errorMessage)
Dispatchers.resetMain()
testApiClient.reset()
}
@Test // When
fun `initial state should be empty`() { viewModel.clearError()
// Given & When - initial state
val initialState = viewModel.uiState
// Then // Then
assertFalse(initialState.isLoading) assertNull(viewModel.uiState.errorMessage)
assertNull(initialState.simplePingResponse) assertFalse(viewModel.uiState.isLoading)
assertNull(initialState.enhancedPingResponse) }
assertNull(initialState.healthResponse)
assertNull(initialState.errorMessage)
}
@Test @Test
fun `performSimplePing should update state with success response`() = runTest(testDispatcher) { fun `multiple operations should clear previous error messages`() = runTest(testDispatcher) {
// Given // Given - first operation fails
val expectedResponse = PingResponse( testApiClient.shouldThrowException = true
status = "OK", viewModel.performSimplePing()
timestamp = "2025-09-27T21:27:00Z", testDispatcher.scheduler.advanceUntilIdle()
service = "test-service" assertNotNull(viewModel.uiState.errorMessage)
)
testApiClient.simplePingResponse = expectedResponse
// When // When - second operation succeeds
viewModel.performSimplePing() testApiClient.shouldThrowException = false
testDispatcher.scheduler.advanceUntilIdle() val successResponse = PingResponse("SUCCESS", "2025-09-27T21:27:00Z", "test-service")
testApiClient.simplePingResponse = successResponse
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then // Then - error should be cleared
val finalState = viewModel.uiState assertNull(viewModel.uiState.errorMessage)
assertFalse(finalState.isLoading) assertEquals(successResponse, viewModel.uiState.simplePingResponse)
assertEquals(expectedResponse, finalState.simplePingResponse) }
assertNull(finalState.errorMessage)
assertTrue(testApiClient.simplePingCalled)
}
@Test @Test
fun `performSimplePing should set loading state during execution`() = runTest(testDispatcher) { fun `loading state should be false after successful operation`() = runTest(testDispatcher) {
// Given // Given
testApiClient.simulateDelay = true viewModel.performSimplePing()
testApiClient.delayMs = 100 testDispatcher.scheduler.advanceUntilIdle()
// When // Then
viewModel.performSimplePing() assertFalse(viewModel.uiState.isLoading)
testDispatcher.scheduler.advanceTimeBy(1) // Allow the coroutine to start }
// Then - should be loading during execution @Test
assertTrue(viewModel.uiState.isLoading) fun `all operations should call respective API methods`() = runTest(testDispatcher) {
assertNull(viewModel.uiState.errorMessage) // When
viewModel.performSimplePing()
viewModel.performEnhancedPing(true)
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// When - complete the operation // Then
testDispatcher.scheduler.advanceUntilIdle() assertTrue(testApiClient.simplePingCalled)
assertEquals(true, testApiClient.enhancedPingCalledWith)
// Then - should not be loading anymore assertTrue(testApiClient.healthCheckCalled)
assertFalse(viewModel.uiState.isLoading) assertEquals(3, testApiClient.callCount)
} }
@Test
fun `performSimplePing should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Network error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.simplePingResponse)
assertEquals("Simple ping failed: $errorMessage", finalState.errorMessage)
assertTrue(testApiClient.simplePingCalled)
}
@Test
fun `performEnhancedPing should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
testApiClient.enhancedPingResponse = expectedResponse
// When
viewModel.performEnhancedPing(simulate = false)
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.enhancedPingResponse)
assertNull(finalState.errorMessage)
assertEquals(false, testApiClient.enhancedPingCalledWith)
}
@Test
fun `performEnhancedPing should handle simulate parameter correctly`() = runTest(testDispatcher) {
// When
viewModel.performEnhancedPing(simulate = true)
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals(true, testApiClient.enhancedPingCalledWith)
}
@Test
fun `performEnhancedPing should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Enhanced ping error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performEnhancedPing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.enhancedPingResponse)
assertEquals("Enhanced ping failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `performHealthCheck should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
healthy = true
)
testApiClient.healthResponse = expectedResponse
// When
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.healthResponse)
assertNull(finalState.errorMessage)
assertTrue(testApiClient.healthCheckCalled)
}
@Test
fun `performHealthCheck should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Health check error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.healthResponse)
assertEquals("Health check failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `clearError should remove error message from state`() {
// Given - set up an error state by simulating an error
testApiClient.shouldThrowException = true
runTest(testDispatcher) {
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
}
// Verify error is present
assertNotNull(viewModel.uiState.errorMessage)
// When
viewModel.clearError()
// Then
assertNull(viewModel.uiState.errorMessage)
assertFalse(viewModel.uiState.isLoading)
}
@Test
fun `multiple operations should clear previous error messages`() = runTest(testDispatcher) {
// Given - first operation fails
testApiClient.shouldThrowException = true
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
assertNotNull(viewModel.uiState.errorMessage)
// When - second operation succeeds
testApiClient.shouldThrowException = false
val successResponse = PingResponse("SUCCESS", "2025-09-27T21:27:00Z", "test-service")
testApiClient.simplePingResponse = successResponse
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then - error should be cleared
assertNull(viewModel.uiState.errorMessage)
assertEquals(successResponse, viewModel.uiState.simplePingResponse)
}
@Test
fun `loading state should be false after successful operation`() = runTest(testDispatcher) {
// Given
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.isLoading)
}
@Test
fun `all operations should call respective API methods`() = runTest(testDispatcher) {
// When
viewModel.performSimplePing()
viewModel.performEnhancedPing(true)
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertTrue(testApiClient.simplePingCalled)
assertEquals(true, testApiClient.enhancedPingCalledWith)
assertTrue(testApiClient.healthCheckCalled)
assertEquals(3, testApiClient.callCount)
}
} }
@@ -11,95 +11,95 @@ import at.mocode.ping.api.HealthResponse
*/ */
class TestPingApiClient : PingApi { class TestPingApiClient : PingApi {
// Test configuration properties // Test configuration properties
var shouldThrowException = false var shouldThrowException = false
var exceptionMessage = "Test exception" var exceptionMessage = "Test exception"
var simulateDelay = false var simulateDelay = false
var delayMs = 100L var delayMs = 100L
// Response configuration // Response configuration
var simplePingResponse: PingResponse? = null var simplePingResponse: PingResponse? = null
var enhancedPingResponse: EnhancedPingResponse? = null var enhancedPingResponse: EnhancedPingResponse? = null
var healthResponse: HealthResponse? = null var healthResponse: HealthResponse? = null
// Call tracking // Call tracking
var simplePingCalled = false var simplePingCalled = false
var enhancedPingCalledWith: Boolean? = null var enhancedPingCalledWith: Boolean? = null
var healthCheckCalled = false var healthCheckCalled = false
var callCount = 0 var callCount = 0
override suspend fun simplePing(): PingResponse { override suspend fun simplePing(): PingResponse {
simplePingCalled = true simplePingCalled = true
callCount++ callCount++
if (simulateDelay) { if (simulateDelay) {
kotlinx.coroutines.delay(delayMs) kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return simplePingResponse ?: PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service"
)
} }
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { if (shouldThrowException) {
enhancedPingCalledWith = simulate throw Exception(exceptionMessage)
callCount++
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return enhancedPingResponse ?: EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
} }
override suspend fun healthCheck(): HealthResponse { return simplePingResponse ?: PingResponse(
healthCheckCalled = true status = "OK",
callCount++ timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service"
)
}
if (simulateDelay) { override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
kotlinx.coroutines.delay(delayMs) enhancedPingCalledWith = simulate
} callCount++
if (shouldThrowException) { if (simulateDelay) {
throw Exception(exceptionMessage) kotlinx.coroutines.delay(delayMs)
}
return healthResponse ?: HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
healthy = true
)
} }
// Test utilities if (shouldThrowException) {
fun reset() { throw Exception(exceptionMessage)
shouldThrowException = false
exceptionMessage = "Test exception"
simulateDelay = false
delayMs = 100L
simplePingResponse = null
enhancedPingResponse = null
healthResponse = null
simplePingCalled = false
enhancedPingCalledWith = null
healthCheckCalled = false
callCount = 0
} }
return enhancedPingResponse ?: EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
}
override suspend fun healthCheck(): HealthResponse {
healthCheckCalled = true
callCount++
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return healthResponse ?: HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
healthy = true
)
}
// Test utilities
fun reset() {
shouldThrowException = false
exceptionMessage = "Test exception"
simulateDelay = false
delayMs = 100L
simplePingResponse = null
enhancedPingResponse = null
healthResponse = null
simplePingCalled = false
enhancedPingCalledWith = null
healthCheckCalled = false
callCount = 0
}
} }
+6 -1
View File
@@ -36,7 +36,7 @@ kotlin {
// ... // ...
} }
// WASM, nur wenn explizit aktiviert // WASM, nur wenn explizit aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(ExperimentalWasmDsl::class) @OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() } wasmJs { browser() }
@@ -58,6 +58,11 @@ kotlin {
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth) implementation(libs.ktor.client.auth)
// Dependency Injection (Koin)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Compose für shared UI components (common) // Compose für shared UI components (common)
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
+47 -45
View File
@@ -1,57 +1,59 @@
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
} }
kotlin { kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true" val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
js(IR) { js(IR) {
browser() browser()
nodejs() // nodejs()
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
commonMain.dependencies {
// Shared module dependency
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
// Serialization
implementation(libs.kotlinx.serialization.json)
// DateTime
implementation(libs.kotlinx.datetime)
} }
// WASM, nur wenn explizit aktiviert jsMain.dependencies {
if (enableWasm) { // JS-specific UI dependencies if needed
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
} }
sourceSets { jvmMain.dependencies {
commonMain.dependencies { // JVM-specific UI dependencies if needed
// Shared module dependency
implementation(project(":clients:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
// Serialization
implementation(libs.kotlinx.serialization.json)
// DateTime
implementation(libs.kotlinx.datetime)
}
jsMain.dependencies {
// JS-specific UI dependencies if needed
}
jvmMain.dependencies {
// JVM-specific UI dependencies if needed
}
} }
}
} }
@@ -12,18 +12,18 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun AppFooter() { fun AppFooter() {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "© 2025 Meldestelle - Built with Kotlin Multiplatform", text = "© 2025 Meldestelle - Built with Kotlin Multiplatform",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
} }
@@ -7,68 +7,68 @@ import androidx.compose.ui.text.font.FontWeight
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppHeader( fun AppHeader(
title: String, title: String,
onNavigateToPing: (() -> Unit)? = null, onNavigateToPing: (() -> Unit)? = null,
onNavigateToLogin: (() -> Unit)? = null, onNavigateToLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
isAuthenticated: Boolean = false, isAuthenticated: Boolean = false,
username: String? = null, username: String? = null,
userPermissions: List<String> = emptyList() userPermissions: List<String> = emptyList()
) { ) {
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
}, },
actions = { actions = {
// Ping Service button // Ping Service button
onNavigateToPing?.let { navigateAction -> onNavigateToPing?.let { navigateAction ->
TextButton( TextButton(
onClick = navigateAction onClick = navigateAction
) { ) {
Text("Ping Service") Text("Ping Service")
} }
} }
// Authentication buttons // Authentication buttons
if (isAuthenticated) { if (isAuthenticated) {
// Show username with admin indicator if user has delete permissions // Show username with admin indicator if user has delete permissions
username?.let { user -> username?.let { user ->
val isAdmin = userPermissions.any { it.contains("DELETE") } val isAdmin = userPermissions.any { it.contains("DELETE") }
Text( Text(
text = if (isAdmin) "👑 Hallo, $user (Admin)" else "Hallo, $user", text = if (isAdmin) "👑 Hallo, $user (Admin)" else "Hallo, $user",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (isAdmin) color = if (isAdmin)
MaterialTheme.colorScheme.tertiary MaterialTheme.colorScheme.tertiary
else else
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
) )
} }
onLogout?.let { logoutAction -> onLogout?.let { logoutAction ->
TextButton( TextButton(
onClick = logoutAction onClick = logoutAction
) { ) {
Text("Abmelden") Text("Abmelden")
} }
} }
} else { } else {
// Show login button // Show login button
onNavigateToLogin?.let { loginAction -> onNavigateToLogin?.let { loginAction ->
TextButton( TextButton(
onClick = loginAction onClick = loginAction
) { ) {
Text("Anmelden") Text("Anmelden")
} }
} }
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) )
)
} }
@@ -10,19 +10,19 @@ import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppScaffold( fun AppScaffold(
header: @Composable () -> Unit = { header: @Composable () -> Unit = {
AppHeader(title = "Meldestelle") AppHeader(title = "Meldestelle")
}, },
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
footer: @Composable () -> Unit = { footer: @Composable () -> Unit = {
AppFooter() AppFooter()
}, },
) { ) {
Scaffold( Scaffold(
topBar = header, topBar = header,
bottomBar = footer, bottomBar = footer,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { paddingValues -> ) { paddingValues ->
content(paddingValues) content(paddingValues)
} }
} }
@@ -9,101 +9,101 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
enum class LoadingSize { enum class LoadingSize {
SMALL, MEDIUM, LARGE SMALL, MEDIUM, LARGE
} }
@Composable @Composable
fun LoadingIndicator( fun LoadingIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: LoadingSize = LoadingSize.MEDIUM, size: LoadingSize = LoadingSize.MEDIUM,
message: String? = null message: String? = null
) { ) {
val indicatorSize = when (size) { val indicatorSize = when (size) {
LoadingSize.SMALL -> 24.dp LoadingSize.SMALL -> 24.dp
LoadingSize.MEDIUM -> 32.dp LoadingSize.MEDIUM -> 32.dp
LoadingSize.LARGE -> 48.dp LoadingSize.LARGE -> 48.dp
} }
val strokeWidth = when (size) { val strokeWidth = when (size) {
LoadingSize.SMALL -> 2.dp LoadingSize.SMALL -> 2.dp
LoadingSize.MEDIUM -> 3.dp LoadingSize.MEDIUM -> 3.dp
LoadingSize.LARGE -> 4.dp LoadingSize.LARGE -> 4.dp
} }
Column( Column(
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(indicatorSize), modifier = Modifier.size(indicatorSize),
strokeWidth = strokeWidth strokeWidth = strokeWidth
) )
if (message != null) { if (message != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
}
} }
}
} }
@Composable @Composable
fun FullScreenLoading( fun FullScreenLoading(
message: String = "Loading...", message: String = "Loading...",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
LoadingIndicator( LoadingIndicator(
size = LoadingSize.LARGE, size = LoadingSize.LARGE,
message = message message = message
) )
} }
} }
@Composable @Composable
fun InlineLoading( fun InlineLoading(
message: String? = null, message: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier.padding(16.dp), modifier = modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
LoadingIndicator( LoadingIndicator(
size = LoadingSize.SMALL, size = LoadingSize.SMALL,
message = message message = message
) )
} }
} }
@Composable @Composable
fun LinearLoadingIndicator( fun LinearLoadingIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: String? = null message: String? = null
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (message != null) { if (message != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
}
} }
}
} }
@@ -1,125 +1,124 @@
package at.mocode.clients.shared.commonui.components package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
enum class ButtonVariant { enum class ButtonVariant {
PRIMARY, SECONDARY, OUTLINE, TEXT PRIMARY, SECONDARY, OUTLINE, TEXT
} }
enum class ButtonSize { enum class ButtonSize {
SMALL, MEDIUM, LARGE SMALL, MEDIUM, LARGE
} }
@Composable @Composable
fun MeldestelleButton( fun MeldestelleButton(
text: String, text: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.PRIMARY, variant: ButtonVariant = ButtonVariant.PRIMARY,
size: ButtonSize = ButtonSize.MEDIUM, size: ButtonSize = ButtonSize.MEDIUM,
enabled: Boolean = true, enabled: Boolean = true,
isLoading: Boolean = false, isLoading: Boolean = false,
fullWidth: Boolean = false fullWidth: Boolean = false
) { ) {
val buttonModifier = modifier.then( val buttonModifier = modifier.then(
if (fullWidth) Modifier.fillMaxWidth() else Modifier if (fullWidth) Modifier.fillMaxWidth() else Modifier
).then( ).then(
when (size) { when (size) {
ButtonSize.SMALL -> Modifier.height(32.dp) ButtonSize.SMALL -> Modifier.height(32.dp)
ButtonSize.MEDIUM -> Modifier.height(40.dp) ButtonSize.MEDIUM -> Modifier.height(40.dp)
ButtonSize.LARGE -> Modifier.height(48.dp) ButtonSize.LARGE -> Modifier.height(48.dp)
}
)
when (variant) {
ButtonVariant.PRIMARY -> Button(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.SECONDARY -> FilledTonalButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.OUTLINE -> OutlinedButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.TEXT -> TextButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
} }
)
when (variant) {
ButtonVariant.PRIMARY -> Button(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.SECONDARY -> FilledTonalButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.OUTLINE -> OutlinedButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
ButtonVariant.TEXT -> TextButton(
onClick = onClick,
modifier = buttonModifier,
enabled = enabled && !isLoading
) {
ButtonContent(text = text, isLoading = isLoading)
}
}
} }
@Composable @Composable
private fun ButtonContent( private fun ButtonContent(
text: String, text: String,
isLoading: Boolean isLoading: Boolean
) { ) {
if (isLoading) { if (isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} else { } else {
Text(text) Text(text)
} }
} }
@Composable @Composable
fun PrimaryButton( fun PrimaryButton(
text: String, text: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
isLoading: Boolean = false, isLoading: Boolean = false,
fullWidth: Boolean = false fullWidth: Boolean = false
) = MeldestelleButton( ) = MeldestelleButton(
text = text, text = text,
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
variant = ButtonVariant.PRIMARY, variant = ButtonVariant.PRIMARY,
enabled = enabled, enabled = enabled,
isLoading = isLoading, isLoading = isLoading,
fullWidth = fullWidth fullWidth = fullWidth
) )
@Composable @Composable
fun SecondaryButton( fun SecondaryButton(
text: String, text: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
isLoading: Boolean = false, isLoading: Boolean = false,
fullWidth: Boolean = false fullWidth: Boolean = false
) = MeldestelleButton( ) = MeldestelleButton(
text = text, text = text,
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
variant = ButtonVariant.SECONDARY, variant = ButtonVariant.SECONDARY,
enabled = enabled, enabled = enabled,
isLoading = isLoading, isLoading = isLoading,
fullWidth = fullWidth fullWidth = fullWidth
) )
@@ -17,176 +17,177 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun MeldestelleTextField( fun MeldestelleTextField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
label: String? = null, label: String? = null,
placeholder: String? = null, placeholder: String? = null,
leadingIcon: ImageVector? = null, leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null, trailingIcon: ImageVector? = null,
onTrailingIconClick: (() -> Unit)? = null, onTrailingIconClick: (() -> Unit)? = null,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
helperText: String? = null, helperText: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
readOnly: Boolean = false, readOnly: Boolean = false,
singleLine: Boolean = true, singleLine: Boolean = true,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Default, imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None visualTransformation: VisualTransformation = VisualTransformation.None
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = label?.let { { Text(it) } }, label = label?.let { { Text(it) } },
placeholder = placeholder?.let { { Text(it) } }, placeholder = placeholder?.let { { Text(it) } },
leadingIcon = leadingIcon?.let { icon -> leadingIcon = leadingIcon?.let { icon ->
{ Icon(imageVector = icon, contentDescription = null) } { Icon(imageVector = icon, contentDescription = null) }
}, },
trailingIcon = if (trailingIcon != null) { trailingIcon = if (trailingIcon != null) {
{ {
IconButton( IconButton(
onClick = onTrailingIconClick ?: {} onClick = onTrailingIconClick ?: {}
) { ) {
Icon(imageVector = trailingIcon, contentDescription = null) Icon(imageVector = trailingIcon, contentDescription = null)
} }
}
} else null,
isError = isError,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction
),
keyboardActions = keyboardActions,
visualTransformation = visualTransformation
)
// Error or helper text
when {
isError && errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
helperText != null -> {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
} }
} else null,
isError = isError,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction
),
keyboardActions = keyboardActions,
visualTransformation = visualTransformation
)
// Error or helper text
when {
isError && errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
helperText != null -> {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
} }
}
} }
@Composable @Composable
fun MeldestellePasswordField( fun MeldestellePasswordField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
label: String = "Password", label: String = "Password",
placeholder: String? = null, placeholder: String? = null,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
helperText: String? = null, helperText: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
imeAction: ImeAction = ImeAction.Done, imeAction: ImeAction = ImeAction.Done,
keyboardActions: KeyboardActions = KeyboardActions.Default keyboardActions: KeyboardActions = KeyboardActions.Default
) { ) {
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
MeldestelleTextField( MeldestelleTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = modifier, modifier = modifier,
label = label, label = label,
placeholder = placeholder, placeholder = placeholder,
trailingIcon = if (passwordVisible) { trailingIcon = if (passwordVisible) {
// You would need to import the actual icon from Material Icons // You would need to import the actual icon from Material Icons
null // Placeholder for visibility off icon null // Placeholder for visibility off icon
} else { } else {
null // Placeholder for visibility on icon null // Placeholder for visibility on icon
}, },
onTrailingIconClick = { passwordVisible = !passwordVisible }, onTrailingIconClick = { passwordVisible = !passwordVisible },
isError = isError, isError = isError,
errorMessage = errorMessage, errorMessage = errorMessage,
helperText = helperText, helperText = helperText,
enabled = enabled, enabled = enabled,
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = imeAction, imeAction = imeAction,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
visualTransformation = if (passwordVisible) { visualTransformation = if (passwordVisible) {
VisualTransformation.None VisualTransformation.None
} else { } else {
PasswordVisualTransformation() PasswordVisualTransformation()
} }
) )
} }
@Composable @Composable
fun MeldestelleEmailField( fun MeldestelleEmailField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
label: String = "Email", label: String = "Email",
placeholder: String? = null, placeholder: String? = null,
isError: Boolean = false, isError: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
helperText: String? = null, helperText: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
imeAction: ImeAction = ImeAction.Next, imeAction: ImeAction = ImeAction.Next,
keyboardActions: KeyboardActions = KeyboardActions.Default keyboardActions: KeyboardActions = KeyboardActions.Default
) { ) {
MeldestelleTextField( MeldestelleTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = modifier, modifier = modifier,
label = label, label = label,
placeholder = placeholder, placeholder = placeholder,
isError = isError, isError = isError,
errorMessage = errorMessage, errorMessage = errorMessage,
helperText = helperText, helperText = helperText,
enabled = enabled, enabled = enabled,
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = imeAction, imeAction = imeAction,
keyboardActions = keyboardActions keyboardActions = keyboardActions
) )
} }
/** /**
* Form validation utilities * Form validation utilities
*/ */
object FormValidation { object FormValidation {
fun validateEmail(email: String): String? { fun validateEmail(email: String): String? {
return when { return when {
email.isEmpty() -> "Email is required" email.isEmpty() -> "Email is required"
!email.contains("@") -> "Invalid email format" !email.contains("@") -> "Invalid email format"
!email.matches(Regex("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$")) -> "Invalid email format" !email.matches(Regex("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$")) -> "Invalid email format"
else -> null else -> null
}
} }
}
fun validatePassword(password: String): String? { fun validatePassword(password: String): String? {
return when { return when {
password.isEmpty() -> "Password is required" password.isEmpty() -> "Password is required"
password.length < 6 -> "Password must be at least 6 characters" password.length < 6 -> "Password must be at least 6 characters"
else -> null else -> null
}
} }
}
fun validateRequired(value: String, fieldName: String): String? { fun validateRequired(value: String, fieldName: String): String? {
return if (value.isEmpty()) "$fieldName is required" else null return if (value.isEmpty()) "$fieldName is required" else null
} }
} }
@@ -1,179 +1,5 @@
package at.mocode.clients.shared.commonui.components package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.* // Legacy notification components removed due to dependency on old presentation layer.
import androidx.compose.foundation.lazy.LazyColumn // Intentionally left empty as part of cleanup. You can safely delete this file
import androidx.compose.foundation.shape.RoundedCornerShape // if no modules import it anymore.
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.presentation.state.Notification
import at.mocode.clients.shared.presentation.state.NotificationType
@Composable
fun NotificationCard(
notification: Notification,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = when (notification.type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50).copy(alpha = 0.1f)
NotificationType.ERROR -> Color(0xFFF44336).copy(alpha = 0.1f)
NotificationType.WARNING -> Color(0xFFFF9800).copy(alpha = 0.1f)
NotificationType.INFO -> Color(0xFF2196F3).copy(alpha = 0.1f)
}
val borderColor = when (notification.type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50)
NotificationType.ERROR -> Color(0xFFF44336)
NotificationType.WARNING -> Color(0xFFFF9800)
NotificationType.INFO -> Color(0xFF2196F3)
}
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = backgroundColor),
shape = RoundedCornerShape(8.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, borderColor)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = notification.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
if (notification.message.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = notification.message,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = notification.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.size(24.dp)
) {
Text("×", style = MaterialTheme.typography.titleMedium)
}
}
}
}
@Composable
fun NotificationList(
notifications: List<Notification>,
onDismissNotification: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
notifications.forEach { notification ->
NotificationCard(
notification = notification,
onDismiss = { onDismissNotification(notification.id) }
)
}
}
}
@Composable
fun SnackbarNotification(
notification: Notification,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = when (notification.type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50)
NotificationType.ERROR -> Color(0xFFF44336)
NotificationType.WARNING -> Color(0xFFFF9800)
NotificationType.INFO -> Color(0xFF2196F3)
}
Snackbar(
modifier = modifier,
containerColor = backgroundColor,
contentColor = Color.White,
action = {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(
contentColor = Color.White
)
) {
Text("Dismiss")
}
}
) {
Column {
Text(
text = notification.title,
fontWeight = FontWeight.SemiBold
)
if (notification.message.isNotBlank()) {
Text(
text = notification.message,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
@Composable
fun ToastNotification(
message: String,
type: NotificationType = NotificationType.INFO,
visible: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
if (visible) {
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(3000) // Auto dismiss after 3 seconds
onDismiss()
}
val backgroundColor = when (type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50)
NotificationType.ERROR -> Color(0xFFF44336)
NotificationType.WARNING -> Color(0xFFFF9800)
NotificationType.INFO -> Color(0xFF2196F3)
}
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = backgroundColor),
shape = RoundedCornerShape(24.dp)
) {
Text(
text = message,
color = Color.White,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@@ -1,232 +0,0 @@
package at.mocode.clients.shared.commonui.layout
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
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.clients.shared.commonui.components.*
import at.mocode.clients.shared.commonui.screens.LoginScreenContainer
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
var showUserMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = "Meldestelle",
fontWeight = FontWeight.Bold
)
},
actions = {
// Notifications
if (appState.ui.notifications.isNotEmpty()) {
BadgedBox(
badge = {
Badge(
contentColor = MaterialTheme.colorScheme.onError,
containerColor = MaterialTheme.colorScheme.error
) {
Text(appState.ui.notifications.size.toString())
}
}
) {
IconButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("🔔")
}
}
} else {
IconButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("🔔")
}
}
// Theme toggle
IconButton(
onClick = { onDispatchAction(AppAction.UI.ToggleDarkMode) }
) {
Text(if (appState.ui.isDarkMode) "☀️" else "🌙")
}
// User menu
Box {
IconButton(
onClick = { showUserMenu = true }
) {
Text("👤")
}
DropdownMenu(
expanded = showUserMenu,
onDismissRequest = { showUserMenu = false }
) {
DropdownMenuItem(
text = {
Column {
Text(
text = appState.auth.user?.firstName ?: "User",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = appState.auth.user?.email ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
showUserMenu = false
onNavigateTo("/profile")
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Settings") },
onClick = {
showUserMenu = false
onNavigateTo("/settings")
}
)
DropdownMenuItem(
text = { Text("Help") },
onClick = {
showUserMenu = false
onNavigateTo("/help")
}
)
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
text = "Logout",
color = MaterialTheme.colorScheme.error
)
},
onClick = {
showUserMenu = false
onDispatchAction(AppAction.Auth.Logout)
}
)
}
}
}
)
},
bottomBar = {
if (appState.ui.notifications.isNotEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${appState.ui.notifications.size} notification(s)",
style = MaterialTheme.typography.bodySmall
)
TextButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("View All")
}
}
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Loading overlay
if (appState.ui.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
FullScreenLoading("Loading...")
}
} else {
content()
}
}
}
}
@Composable
fun AuthenticatedLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
if (appState.auth.isAuthenticated) {
MainLayout(
appState = appState,
onDispatchAction = onDispatchAction,
onNavigateTo = onNavigateTo,
content = content,
modifier = modifier
)
} else {
// Show login screen if not authenticated
LoginScreenContainer(
authState = appState.auth,
onDispatchAction = onDispatchAction,
modifier = modifier
)
}
}
@Composable
fun ResponsiveLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
// Simple responsive design - could be enhanced with actual screen size detection
val isCompact = remember { mutableStateOf(false) }
AuthenticatedLayout(
appState = appState,
onDispatchAction = onDispatchAction,
onNavigateTo = onNavigateTo,
content = { content(isCompact.value) },
modifier = modifier
)
}
@@ -1,250 +0,0 @@
package at.mocode.clients.shared.commonui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.clients.shared.commonui.components.*
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val user = appState.auth.user
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Welcome Header
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Text(
text = "Welcome back, ${user?.firstName ?: "User"}!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Here's what's happening in your Meldestelle dashboard",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
// Quick Actions
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PrimaryButton(
text = "New Report",
onClick = { onNavigateTo("/reports/new") },
modifier = Modifier.weight(1f)
)
SecondaryButton(
text = "View Reports",
onClick = { onNavigateTo("/reports") },
modifier = Modifier.weight(1f)
)
}
// Statistics Cards
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
title = "Total Reports",
value = "142",
modifier = Modifier.weight(1f)
)
StatisticCard(
title = "Open Issues",
value = "23",
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
title = "Resolved",
value = "119",
modifier = Modifier.weight(1f)
)
StatisticCard(
title = "This Month",
value = "18",
modifier = Modifier.weight(1f)
)
}
// Recent Activity
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ActivityItem(
title = "Report #1234 updated",
subtitle = "Status changed to 'In Progress'",
timestamp = "2 hours ago"
)
HorizontalDivider()
ActivityItem(
title = "New report submitted",
subtitle = "Report #1235 - Urgent priority",
timestamp = "4 hours ago"
)
HorizontalDivider()
ActivityItem(
title = "Report #1230 resolved",
subtitle = "Issue successfully closed",
timestamp = "1 day ago"
)
}
}
// Connection Status
if (!appState.network.isOnline) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Offline Mode",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Some features may be limited",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
@Composable
private fun StatisticCard(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ActivityItem(
title: String,
subtitle: String,
timestamp: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
@@ -1,198 +0,0 @@
package at.mocode.clients.shared.commonui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.presentation.actions.AppAction
import at.mocode.clients.shared.presentation.state.AuthState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
authState: AuthState,
onLoginClick: (String, String) -> Unit,
onNavigateToRegister: () -> Unit = {},
onForgotPassword: () -> Unit = {},
modifier: Modifier = Modifier
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var usernameError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
val focusManager = LocalFocusManager.current
// Validate form
val isFormValid = username.isNotBlank() && password.isNotBlank() &&
usernameError == null && passwordError == null
fun validateUsername(value: String) {
usernameError = FormValidation.validateRequired(value, "Username")
}
fun validatePassword(value: String) {
passwordError = FormValidation.validatePassword(value)
}
fun handleLogin() {
validateUsername(username)
validatePassword(password)
if (isFormValid) {
onLoginClick(username.trim(), password)
}
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Text(
text = "Meldestelle",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "Sign in to your account",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Username Field
MeldestelleTextField(
value = username,
onValueChange = {
username = it
if (usernameError != null) validateUsername(it)
},
label = "Username",
placeholder = "Enter your username",
isError = usernameError != null,
errorMessage = usernameError,
enabled = !authState.isLoading,
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
// Password Field
MeldestellePasswordField(
value = password,
onValueChange = {
password = it
if (passwordError != null) validatePassword(it)
},
label = "Password",
placeholder = "Enter your password",
isError = passwordError != null,
errorMessage = passwordError,
enabled = !authState.isLoading,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (isFormValid) handleLogin()
}
)
)
// Error display
authState.error?.let { errorMessage ->
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(8.dp))
// Login Button
PrimaryButton(
text = "Sign In",
onClick = ::handleLogin,
enabled = isFormValid && !authState.isLoading,
isLoading = authState.isLoading,
fullWidth = true
)
// Forgot Password
TextButton(
onClick = onForgotPassword,
enabled = !authState.isLoading
) {
Text("Forgot Password?")
}
HorizontalDivider()
// Register Link
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Don't have an account?",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
TextButton(
onClick = onNavigateToRegister,
enabled = !authState.isLoading
) {
Text("Sign Up")
}
}
}
}
}
}
@Composable
fun LoginScreenContainer(
authState: AuthState,
onDispatchAction: (AppAction) -> Unit,
onNavigateToRegister: () -> Unit = {},
onForgotPassword: () -> Unit = {},
modifier: Modifier = Modifier
) {
LoginScreen(
authState = authState,
onLoginClick = { username, password ->
onDispatchAction(AppAction.Auth.LoginStart(username, password))
},
onNavigateToRegister = onNavigateToRegister,
onForgotPassword = onForgotPassword,
modifier = modifier
)
}
@@ -8,42 +8,42 @@ import androidx.compose.ui.graphics.Color
// Define custom colors for the app // Define custom colors for the app
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2), primary = Color(0xFF1976D2),
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB), primaryContainer = Color(0xFFBBDEFB),
onPrimaryContainer = Color(0xFF0D47A1), onPrimaryContainer = Color(0xFF0D47A1),
secondary = Color(0xFF03DAC6), secondary = Color(0xFF03DAC6),
onSecondary = Color.Black, onSecondary = Color.Black,
tertiary = Color(0xFF03A9F4), tertiary = Color(0xFF03A9F4),
background = Color(0xFFFAFAFA), background = Color(0xFFFAFAFA),
surface = Color.White, surface = Color.White,
onBackground = Color(0xFF1C1B1F), onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F) onSurface = Color(0xFF1C1B1F)
) )
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9), primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF0D47A1), onPrimary = Color(0xFF0D47A1),
primaryContainer = Color(0xFF1565C0), primaryContainer = Color(0xFF1565C0),
onPrimaryContainer = Color(0xFFBBDEFB), onPrimaryContainer = Color(0xFFBBDEFB),
secondary = Color(0xFF03DAC6), secondary = Color(0xFF03DAC6),
onSecondary = Color.Black, onSecondary = Color.Black,
tertiary = Color(0xFF03A9F4), tertiary = Color(0xFF03A9F4),
background = Color(0xFF121212), background = Color(0xFF121212),
surface = Color(0xFF1E1E1E), surface = Color(0xFF1E1E1E),
onBackground = Color(0xFFE0E0E0), onBackground = Color(0xFFE0E0E0),
onSurface = Color(0xFFE0E0E0) onSurface = Color(0xFFE0E0E0)
) )
@Composable @Composable
fun AppTheme( fun AppTheme(
darkTheme: Boolean = false, // For now, we'll default to light theme darkTheme: Boolean = false, // For now, we'll default to light theme
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
content = content content = content
) )
} }
+21 -19
View File
@@ -3,36 +3,38 @@
* Es ist noch simpler. * Es ist noch simpler.
*/ */
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
} }
group = "at.mocode.clients.shared" group = "at.mocode.clients.shared"
version = "1.0.0" version = "1.0.0"
kotlin { kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true" val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
js { js {
browser() browser()
binaries.executable()
}
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
} }
}
if (enableWasm) { sourceSets {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) commonMain.dependencies {
wasmJs { // No specific dependencies needed for navigation routes
browser()
}
} }
commonTest.dependencies {
sourceSets { implementation(libs.kotlin.test)
commonMain.dependencies {
// No specific dependencies needed for navigation routes
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
} }
}
} }
@@ -1,8 +1,9 @@
package at.mocode.clients.shared.navigation package at.mocode.clients.shared.navigation
sealed class AppScreen { sealed class AppScreen {
data object Home : AppScreen() data object Home : AppScreen()
data object Login : AppScreen() data object Login : AppScreen()
data object Ping : AppScreen() data object Ping : AppScreen()
data object Profile : AppScreen() data object Profile : AppScreen()
data object AuthCallback : AppScreen()
} }
@@ -1,17 +0,0 @@
package at.mocode.clients.shared
/**
* Zentrale App-Konfiguration für alle Client-Module.
* Hinweis: Diese Werte sind zentrale Defaults für DEV. Für PROD sollten sie
* via Build-Injektion (Gradle/ENV) überschrieben werden. Ein einfaches
* BuildConfig-Setup kann später ergänzt werden.
*/
object AppConfig {
// Gateway Basis-URL (API Gateway)
const val GATEWAY_URL: String = "http://localhost:8081"
// Keycloak Konfiguration
const val KEYCLOAK_URL: String = "http://localhost:8180"
const val KEYCLOAK_REALM: String = "meldestelle"
const val KEYCLOAK_CLIENT_ID: String = "meldestelle-frontend"
}
@@ -0,0 +1,12 @@
package at.mocode.clients.shared.core
data class AppConfig(
val gatewayUrl: String,
val isDebug: Boolean
)
// Standard-Config für Local Development
val devConfig = AppConfig(
gatewayUrl = "http://localhost:8081",
isDebug = true
)
@@ -0,0 +1,47 @@
package at.mocode.clients.shared.core
/**
* Shared application configuration constants for clients.
* These defaults target local development environments.
*/
object AppConstants {
// Gateway base URL (reverse proxy / API gateway)
const val GATEWAY_URL: String = "http://localhost:8081"
// Keycloak configuration
const val KEYCLOAK_URL: String = "http://localhost:8180"
const val KEYCLOAK_REALM: String = "meldestelle"
// Use public client configured in realm import: `web-app`
const val KEYCLOAK_CLIENT_ID: String = "web-app"
// Default redirect URI for web PKCE flow (served by Nginx in web image)
// We use the root path so Keycloak can redirect back to /?code=...
fun webRedirectUri(): String = "http://localhost:4000/"
fun registerUrl(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/registrations?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
encode(
webRedirectUri()
)
}"
fun loginUrl(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
encode(
webRedirectUri()
)
}"
fun authorizeEndpoint(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth"
fun tokenEndpoint(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token"
fun desktopDownloadUrl(): String = "http://localhost:4000/downloads/"
// Helper to URL-encode values (very small percent-encoding sufficient for URIs here)
private fun encode(value: String): String =
value.replace("://", ":%2F%2F").replace("/", "%2F").replace(":", "%3A")
}
@@ -1,171 +0,0 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import at.mocode.clients.shared.domain.models.ApiResponse
import at.mocode.clients.shared.network.HttpClientConfig
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
/**
* Authentication repository handling all authentication-related operations
* with Keycloak integration.
*/
class AuthRepository(
private val baseUrl: String = "http://localhost:8080",
private val keycloakUrl: String = "http://localhost:8180",
private val realm: String = "meldestelle",
private val clientId: String = "meldestelle-client"
) : Repository {
private val httpClient: HttpClient = HttpClientConfig.createClient(baseUrl)
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class KeycloakTokenResponse(
val access_token: String,
val refresh_token: String,
val expires_in: Long,
val token_type: String = "Bearer"
)
/**
* Authenticate user with username and password via Keycloak
*/
suspend fun login(username: String, password: String): RepositoryResult<AuthToken> {
return try {
val response = httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
formParameters = Parameters.build {
append("grant_type", "password")
append("client_id", clientId)
append("username", username)
append("password", password)
}
).body<KeycloakTokenResponse>()
val authToken = AuthToken(
accessToken = response.access_token,
refreshToken = response.refresh_token,
expiresIn = response.expires_in,
tokenType = response.token_type
)
RepositoryResult.Success(authToken)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "LOGIN_FAILED",
message = "Login failed: ${e.message}"
)
)
}
}
/**
* Refresh authentication token
*/
suspend fun refreshToken(refreshToken: String): RepositoryResult<AuthToken> {
return try {
val response = httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
formParameters = Parameters.build {
append("grant_type", "refresh_token")
append("client_id", clientId)
append("refresh_token", refreshToken)
}
).body<KeycloakTokenResponse>()
val authToken = AuthToken(
accessToken = response.access_token,
refreshToken = response.refresh_token,
expiresIn = response.expires_in,
tokenType = response.token_type
)
RepositoryResult.Success(authToken)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "TOKEN_REFRESH_FAILED",
message = "Token refresh failed: ${e.message}"
)
)
}
}
/**
* Get current user information using access token
*/
suspend fun getCurrentUser(accessToken: String): RepositoryResult<User> {
return try {
val response = httpClient.get("$baseUrl/api/auth/me") {
header("Authorization", "Bearer $accessToken")
}.body<ApiResponse<User>>()
response.toRepositoryResult()
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "USER_INFO_FAILED",
message = "Failed to get user info: ${e.message}"
)
)
}
}
/**
* Logout user by invalidating tokens
*/
suspend fun logout(refreshToken: String): RepositoryResult<Unit> {
return try {
httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/logout",
formParameters = Parameters.build {
append("client_id", clientId)
append("refresh_token", refreshToken)
}
)
RepositoryResult.Success(Unit)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "LOGOUT_FAILED",
message = "Logout failed: ${e.message}"
)
)
}
}
/**
* Check if token is still valid
*/
suspend fun validateToken(accessToken: String): RepositoryResult<Boolean> {
return try {
val response = httpClient.get("$baseUrl/api/auth/validate") {
header("Authorization", "Bearer $accessToken")
}.body<ApiResponse<Boolean>>()
response.toRepositoryResult()
} catch (e: Exception) {
RepositoryResult.Success(false) // Token is invalid
}
}
/**
* Cleanup resources
*/
fun close() {
httpClient.close()
}
}
@@ -0,0 +1,27 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.model.PingData
import at.mocode.clients.shared.domain.model.Resource
import at.mocode.clients.shared.domain.repository.PingRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
class PingRepositoryImpl(
private val httpClient: HttpClient
) : PingRepository {
override suspend fun checkSystemStatus(): Resource<PingData> {
return try {
// Der HttpClient hat die BaseURL schon konfiguriert (siehe NetworkModule)
val response = httpClient.get("/api/ping/simple").body<PingData>()
Resource.Success(response)
} catch (e: Exception) {
// Hier fangen wir Netzwerkfehler ab und machen sie "hübsch" für die UI
Resource.Error(
message = "Verbindung fehlgeschlagen: ${e.message ?: "Unbekannter Fehler"}",
code = "NETWORK_ERROR"
)
}
}
}
@@ -1,73 +0,0 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.models.ApiResponse
import at.mocode.clients.shared.domain.models.ApiError
/**
* Base repository interface defining common operations and patterns
* for data access across the application.
*/
interface Repository
/**
* Result wrapper for repository operations to handle success/error states
*/
sealed class RepositoryResult<out T> {
data class Success<T>(val data: T) : RepositoryResult<T>()
data class Error(val error: ApiError) : RepositoryResult<Nothing>()
data class Loading(val message: String = "Loading...") : RepositoryResult<Nothing>()
fun isSuccess(): Boolean = this is Success
fun isError(): Boolean = this is Error
fun isLoading(): Boolean = this is Loading
fun getOrNull(): T? = when (this) {
is Success -> data
else -> null
}
fun getErrorOrNull(): ApiError? = when (this) {
is Error -> error
else -> null
}
}
/**
* Extension function to convert ApiResponse to RepositoryResult
*/
fun <T> ApiResponse<T>.toRepositoryResult(): RepositoryResult<T> {
return if (success && data != null) {
RepositoryResult.Success(data)
} else {
RepositoryResult.Error(
error ?: ApiError(
code = "UNKNOWN_ERROR",
message = "Unknown error occurred"
)
)
}
}
/**
* Extension function to handle repository results with callbacks
*/
inline fun <T> RepositoryResult<T>.onSuccess(action: (T) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Success) {
action(data)
}
return this
}
inline fun <T> RepositoryResult<T>.onError(action: (ApiError) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Error) {
action(error)
}
return this
}
inline fun <T> RepositoryResult<T>.onLoading(action: (String) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Loading) {
action(message)
}
return this
}
@@ -0,0 +1,50 @@
package at.mocode.clients.shared.di
import at.mocode.clients.shared.core.AppConfig
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.koin.dsl.module
val networkModule = module {
// 1. JSON Konfiguration (Global verfügbar)
single {
Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
}
// 2. HttpClient (Singleton)
single {
val config = get<AppConfig>()
val jsonConfig = get<Json>()
HttpClient {
// Standard-URL setzen
defaultRequest {
url(config.gatewayUrl)
contentType(ContentType.Application.Json)
}
install(ContentNegotiation) {
json(jsonConfig)
}
install(Logging) {
level = if (config.isDebug) LogLevel.INFO else LogLevel.NONE
logger = Logger.DEFAULT
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 10000
}
}
}
}
@@ -0,0 +1,23 @@
package at.mocode.clients.shared.di
import at.mocode.clients.shared.core.devConfig
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module
// Das Modul für die Config
val configModule = module {
single { devConfig } // Später können wir hier PROD/DEV umschalten
}
// Alle Module zusammen
val sharedModules = listOf(
configModule,
networkModule
)
// Helper zum Starten von Koin (wird von der App aufgerufen)
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(sharedModules)
}
@@ -0,0 +1,39 @@
package at.mocode.clients.shared.domain.model
import kotlinx.serialization.Serializable
/**
* Generischer Wrapper für API-Antworten.
*/
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null
)
@Serializable
data class ApiError(
val code: String,
val message: String
)
/**
* Das Ergebnis eines Repository-Aufrufs.
* Die UI kennt nur das hier, keine HTTP-Exceptions!
*/
sealed class Resource<out T> {
data class Success<T>(val data: T) : Resource<T>()
data class Error(val message: String, val code: String? = null) : Resource<Nothing>()
data object Loading : Resource<Nothing>()
}
/**
* Datenmodell für den Ping.
*/
@Serializable
data class PingData(
val status: String,
val timestamp: String,
val service: String
)
@@ -1,27 +0,0 @@
package at.mocode.clients.shared.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null,
val timestamp: String,
val correlationId: String? = null
)
@Serializable
data class ApiError(
val code: String,
val message: String,
val details: Map<String, String> = emptyMap()
)
@Serializable
data class HealthResponse(
val status: String,
val timestamp: String,
val service: String,
val healthy: Boolean
)
@@ -1,22 +0,0 @@
package at.mocode.clients.shared.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val username: String,
val email: String,
val firstName: String,
val lastName: String,
val roles: Set<String> = emptySet(),
val isActive: Boolean = true
)
@Serializable
data class AuthToken(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long,
val tokenType: String = "Bearer"
)
@@ -0,0 +1,8 @@
package at.mocode.clients.shared.domain.repository
import at.mocode.clients.shared.domain.model.PingData
import at.mocode.clients.shared.domain.model.Resource
interface PingRepository {
suspend fun checkSystemStatus(): Resource<PingData>
}
@@ -1,194 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.store.AppStore
/**
* Deep link handling for the application
*/
class DeepLinkHandler(
private val navigationManager: NavigationManager,
private val store: AppStore
) {
/**
* Deep link configuration
*/
data class DeepLinkConfig(
val scheme: String = "meldestelle",
val host: String = "app",
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
)
private val config = DeepLinkConfig()
/**
* Handle a deep link URL
*/
fun handleDeepLink(url: String): Boolean {
return try {
val parsedLink = parseDeepLink(url)
if (parsedLink != null) {
processDeepLink(parsedLink)
true
} else {
false
}
} catch (e: Exception) {
// Log error in real implementation
false
}
}
/**
* Parse deep link URL into components
*/
private fun parseDeepLink(url: String): DeepLink? {
return when {
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
else -> null
}
}
/**
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
*/
private fun parseCustomSchemeLink(url: String): DeepLink? {
val withoutScheme = url.removePrefix("${config.scheme}://")
val parts = withoutScheme.split("/")
if (parts.isEmpty() || parts[0] != config.host) {
return null
}
val path = "/" + parts.drop(1).joinToString("/")
val route = if (path == "/") Routes.HOME else path
return DeepLink(
type = DeepLinkType.CUSTOM_SCHEME,
route = route,
params = RouteUtils.parseRouteParams(route),
originalUrl = url
)
}
/**
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
*/
private fun parseWebLink(url: String): DeepLink? {
// Simple URL parsing - in real implementation use proper URL parser
val urlParts = url.split("/")
if (urlParts.size < 3) return null
val domain = urlParts[2]
if (!config.allowedDomains.contains(domain)) {
return null
}
val path = "/" + urlParts.drop(3).joinToString("/")
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
return DeepLink(
type = DeepLinkType.WEB_LINK,
route = route,
params = RouteUtils.parseRouteParams(route),
originalUrl = url
)
}
/**
* Process a parsed deep link
*/
private fun processDeepLink(deepLink: DeepLink) {
val authState = store.state.value.auth
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
// Check if route requires authentication
if (RouteUtils.requiresAuth(cleanRoute)) {
if (!authState.isAuthenticated) {
// Save the intended route and redirect to log in
saveIntendedRoute(deepLink.route)
navigationManager.navigateTo(Routes.Auth.LOGIN)
return
}
}
// Check if route requires admin privileges
if (RouteUtils.requiresAdmin(cleanRoute)) {
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
if (!hasAdminRole) {
// Redirect to unauthorized or home
navigationManager.navigateTo(Routes.HOME)
return
}
}
// Navigate to the route
navigationManager.navigateTo(deepLink.route)
}
/**
* Save the intended route for after authentication
*/
private fun saveIntendedRoute(route: String) {
// In real implementation, save to persistent storage
// For now; we'll store it in a simple variable
intendedRoute = route
}
/**
* Get and clear the intended route
*/
fun getAndClearIntendedRoute(): String? {
val route = intendedRoute
intendedRoute = null
return route
}
/**
* Check if there's a pending intended route
*/
fun hasIntendedRoute(): Boolean = intendedRoute != null
/**
* Generate a deep link for a route
*/
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
return if (useCustomScheme) {
"${config.scheme}://${config.host}$route"
} else {
"https://${config.allowedDomains.first()}$route"
}
}
/**
* Validate if a route is valid for deep linking
*/
fun isValidDeepLinkRoute(route: String): Boolean {
return RouteUtils.isValidRoute(route) &&
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
route != Routes.Auth.LOGIN
}
companion object {
private var intendedRoute: String? = null
}
}
/**
* Deep link data class
*/
data class DeepLink(
val type: DeepLinkType,
val route: String,
val params: Map<String, String>,
val originalUrl: String
)
/**
* Types of deep links
*/
enum class DeepLinkType {
CUSTOM_SCHEME, // meldestelle://app/route
WEB_LINK // https://meldestelle.com/route
}
@@ -1,179 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.actions.AppAction
import at.mocode.clients.shared.presentation.store.AppStore
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Navigation manager for handling routing and navigation logic
*/
class NavigationManager(
private val store: AppStore
) {
/**
* Current route as a flow
*/
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
/**
* Navigation history as a flow
*/
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
/**
* Can go back flag as a flow
*/
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
/**
* Navigate to a specific route
*/
fun navigateTo(route: String) {
store.dispatch(AppAction.Navigation.NavigateTo(route))
}
/**
* Navigate back to the previous route
*/
fun navigateBack() {
store.dispatch(AppAction.Navigation.NavigateBack)
}
/**
* Replace current route without adding to history
*/
fun replaceRoute(route: String) {
store.dispatch(AppAction.Navigation.UpdateHistory(route))
}
/**
* Clear navigation history and navigate to the route
*/
fun navigateAndClearHistory(route: String) {
// First clear by replacing with the new route
store.dispatch(AppAction.Navigation.UpdateHistory(route))
}
/**
* Get current route value (non-reactive)
*/
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
/**
* Check if we can navigate back
*/
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
}
/**
* Route definitions for the application
*/
object Routes {
const val HOME = "/"
const val LOGIN = "/login"
const val DASHBOARD = "/dashboard"
const val PROFILE = "/profile"
const val SETTINGS = "/settings"
const val PING = "/ping"
// Auth-related routes
object Auth {
const val LOGIN = "/auth/login"
const val LOGOUT = "/auth/logout"
const val REGISTER = "/auth/register"
const val FORGOT_PASSWORD = "/auth/forgot-password"
}
// Admin routes
object Admin {
const val DASHBOARD = "/admin/dashboard"
const val USERS = "/admin/users"
const val SETTINGS = "/admin/settings"
}
// Feature routes
object Features {
const val PING = "/features/ping"
const val REPORTS = "/features/reports"
const val NOTIFICATIONS = "/features/notifications"
}
}
/**
* Route validation and utilities
*/
object RouteUtils {
/**
* Check if a route requires authentication
*/
fun requiresAuth(route: String): Boolean {
return when {
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
route == Routes.HOME -> false
route == Routes.LOGIN -> false
else -> true
}
}
/**
* Check if a route is for admin only
*/
fun requiresAdmin(route: String): Boolean {
return route.startsWith("/admin/")
}
/**
* Get the default route for authenticated users
*/
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
/**
* Get the default route for unauthenticated users
*/
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
/**
* Validate route format
*/
fun isValidRoute(route: String): Boolean {
return route.startsWith("/") && route.isNotBlank()
}
/**
* Parse route parameters (simple implementation)
*/
fun parseRouteParams(route: String): Map<String, String> {
val params = mutableMapOf<String, String>()
// Simple query parameter parsing
if (route.contains("?")) {
val parts = route.split("?")
if (parts.size == 2) {
val queryParams = parts[1].split("&")
queryParams.forEach { param ->
val keyValue = param.split("=")
if (keyValue.size == 2) {
params[keyValue[0]] = keyValue[1]
}
}
}
}
return params
}
/**
* Get clean route without parameters
*/
fun getCleanRoute(route: String): String {
return if (route.contains("?")) {
route.split("?")[0]
} else {
route
}
}
}
@@ -1,74 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.state.NavigationState
import kotlinx.coroutines.flow.Flow
/**
* Interface für das Persistieren von Navigation State
*/
interface NavigationPersistence {
suspend fun saveNavigationState(state: NavigationState)
fun getNavigationState(): Flow<NavigationState?>
suspend fun clearNavigationState()
}
/**
* Default implementation ohne echte Persistierung (In-Memory)
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
*/
class DefaultNavigationPersistence : NavigationPersistence {
private var currentState: NavigationState? = null
override suspend fun saveNavigationState(state: NavigationState) {
currentState = state
}
override fun getNavigationState(): Flow<NavigationState?> {
return kotlinx.coroutines.flow.flowOf(currentState)
}
override suspend fun clearNavigationState() {
currentState = null
}
}
/**
* Navigation History Manager mit Persistierung
*/
class NavigationHistoryManager(
private val persistence: NavigationPersistence
) {
companion object {
private const val MAX_HISTORY_SIZE = 50
}
suspend fun saveRoute(route: String, history: List<String>) {
val state = NavigationState(
currentRoute = route,
history = history.takeLast(MAX_HISTORY_SIZE),
canGoBack = history.isNotEmpty()
)
persistence.saveNavigationState(state)
}
fun getPersistedState() = persistence.getNavigationState()
suspend fun clear() = persistence.clearNavigationState()
/**
* Optimiert die History für bessere Performance
*/
private fun optimizeHistory(history: List<String>): List<String> {
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
return history
.fold(emptyList<String>()) { acc, route ->
if (acc.lastOrNull() != route) acc + route else acc
}
.takeLast(MAX_HISTORY_SIZE)
}
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
saveRoute(newRoute, optimizedHistory.dropLast(1))
}
}
@@ -1,27 +0,0 @@
package at.mocode.clients.shared.network
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object HttpClientConfig {
fun createClient(
baseUrl: String = "http://localhost:8080"
): HttpClient = HttpClient {
// Content negotiation with JSON (based on PingApiClient pattern)
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
return createClient(baseUrl)
}
}
@@ -1,164 +0,0 @@
package at.mocode.clients.shared.network
import at.mocode.clients.shared.domain.models.ApiError
import io.ktor.client.network.sockets.*
import io.ktor.client.plugins.*
import kotlinx.io.IOException
/**
* Custom exceptions for network operations
*/
sealed class NetworkException(
message: String,
cause: Throwable? = null,
val apiError: ApiError
) : Exception(message, cause) {
class ConnectionException(
message: String = "Connection failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "CONNECTION_ERROR",
message = message,
details = mapOf("type" to "network_connectivity")
)
)
class TimeoutException(
message: String = "Request timed out",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "TIMEOUT_ERROR",
message = message,
details = mapOf("type" to "request_timeout")
)
)
class ServerException(
statusCode: Int,
message: String = "Server error",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "SERVER_ERROR",
message = message,
details = mapOf(
"type" to "server_error",
"status_code" to statusCode.toString()
)
)
)
class ClientException(
statusCode: Int,
message: String = "Client error",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "CLIENT_ERROR",
message = message,
details = mapOf(
"type" to "client_error",
"status_code" to statusCode.toString()
)
)
)
class AuthenticationException(
message: String = "Authentication failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "AUTHENTICATION_ERROR",
message = message,
details = mapOf("type" to "authentication_failure")
)
)
class AuthorizationException(
message: String = "Authorization failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "AUTHORIZATION_ERROR",
message = message,
details = mapOf("type" to "authorization_failure")
)
)
class UnknownException(
message: String = "Unknown error occurred",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "UNKNOWN_ERROR",
message = message,
details = mapOf("type" to "unknown_error")
)
)
}
/**
* Extension function to convert various exceptions to NetworkException
*/
fun Throwable.toNetworkException(): NetworkException {
return when (this) {
is ConnectTimeoutException -> NetworkException.TimeoutException(
message = "Connection timeout: ${this.message}",
cause = this
)
is SocketTimeoutException -> NetworkException.TimeoutException(
message = "Socket timeout: ${this.message}",
cause = this
)
is ResponseException -> when (this.response.status.value) {
401 -> NetworkException.AuthenticationException(
message = "Authentication required",
cause = this
)
403 -> NetworkException.AuthorizationException(
message = "Access forbidden",
cause = this
)
in 400..499 -> NetworkException.ClientException(
statusCode = this.response.status.value,
message = "Client error: ${this.message}",
cause = this
)
in 500..599 -> NetworkException.ServerException(
statusCode = this.response.status.value,
message = "Server error: ${this.message}",
cause = this
)
else -> NetworkException.UnknownException(
message = "HTTP error: ${this.message}",
cause = this
)
}
is IOException -> NetworkException.ConnectionException(
message = "Network connection failed: ${this.message}",
cause = this
)
is NetworkException -> this
else -> NetworkException.UnknownException(
message = "Unexpected error: ${this.message}",
cause = this
)
}
}
@@ -1,217 +0,0 @@
package at.mocode.clients.shared.network
import at.mocode.clients.shared.data.repository.RepositoryResult
import at.mocode.clients.shared.domain.models.ApiError
import kotlinx.coroutines.delay
// Using platform-agnostic timestamp handling
/**
* Simple timestamp provider for multiplatform compatibility
*/
expect fun currentTimeMillis(): Long
/**
* Network utilities for handling retry logic and resilience
*/
object NetworkUtils {
/**
* Retry configuration for network operations
*/
data class RetryConfig(
val maxAttempts: Int = 3,
val initialDelayMs: Long = 1000L,
val maxDelayMs: Long = 10000L,
val backoffMultiplier: Double = 2.0,
val retryableExceptions: Set<String> = setOf(
"CONNECTION_ERROR",
"TIMEOUT_ERROR",
"SERVER_ERROR"
)
)
/**
* Execute operation with retry logic
*/
suspend fun <T> withRetry(
config: RetryConfig = RetryConfig(),
operation: suspend () -> RepositoryResult<T>
): RepositoryResult<T> {
var lastError: ApiError? = null
var currentDelay = config.initialDelayMs
repeat(config.maxAttempts) { attempt ->
try {
val result = operation()
// Return success immediately
if (result.isSuccess()) {
return result
}
// Check if the error is retryable
val error = result.getErrorOrNull()
if (error != null && shouldRetry(error, config)) {
lastError = error
// Don't delay on the last attempt
if (attempt < config.maxAttempts - 1) {
delay(currentDelay)
currentDelay = minOf(
(currentDelay * config.backoffMultiplier).toLong(),
config.maxDelayMs
)
}
} else {
// Non-retryable error, return immediately
return result
}
} catch (e: Exception) {
val networkException = e.toNetworkException()
lastError = networkException.apiError
if (shouldRetry(networkException.apiError, config)) {
if (attempt < config.maxAttempts - 1) {
delay(currentDelay)
currentDelay = minOf(
(currentDelay * config.backoffMultiplier).toLong(),
config.maxDelayMs
)
}
} else {
return RepositoryResult.Error(networkException.apiError)
}
}
}
// All attempts exhausted, return last error
return RepositoryResult.Error(
lastError ?: ApiError(
code = "MAX_RETRIES_EXCEEDED",
message = "Maximum retry attempts exceeded"
)
)
}
/**
* Check if an error should trigger a retry
*/
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
return config.retryableExceptions.contains(error.code)
}
/**
* Network connectivity checker (simplified for shared module)
*/
object ConnectivityChecker {
private var isOnline: Boolean = true
private var lastCheckMillis: Long = 0L
fun setOnlineStatus(online: Boolean) {
isOnline = online
lastCheckMillis = currentTimeMillis()
}
fun isOnline(): Boolean = isOnline
fun getLastCheckMillis(): Long = lastCheckMillis
/**
* Simple connectivity test by attempting a lightweight operation
*/
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
return try {
val result = testOperation()
setOnlineStatus(result)
result
} catch (_: Exception) {
setOnlineStatus(false)
false
}
}
}
/**
* Circuit breaker pattern for network operations
*/
class CircuitBreaker(
private val failureThreshold: Int = 5,
private val recoveryTimeoutMs: Long = 60000L,
private val successThreshold: Int = 3
) {
private enum class State { CLOSED, OPEN, HALF_OPEN }
private var state = State.CLOSED
private var failureCount = 0
private var successCount = 0
private var lastFailureTime = 0L
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
when (state) {
State.OPEN -> {
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
state = State.HALF_OPEN
successCount = 0
} else {
return RepositoryResult.Error(
ApiError(
code = "CIRCUIT_BREAKER_OPEN",
message = "Circuit breaker is open, requests blocked"
)
)
}
}
State.HALF_OPEN -> {
// Allow limited requests to test recovery
}
State.CLOSED -> {
// Normal operation
}
}
return try {
val result = operation()
if (result.isSuccess()) {
onSuccess()
} else {
onFailure()
}
result
} catch (e: Exception) {
onFailure()
val networkException = e.toNetworkException()
RepositoryResult.Error(networkException.apiError)
}
}
private fun onSuccess() {
failureCount = 0
when (state) {
State.HALF_OPEN -> {
successCount++
if (successCount >= successThreshold) {
state = State.CLOSED
}
}
else -> {
state = State.CLOSED
}
}
}
private fun onFailure() {
failureCount++
lastFailureTime = currentTimeMillis()
if (failureCount >= failureThreshold) {
state = State.OPEN
}
}
fun getState(): String = state.name
fun getFailureCount(): Int = failureCount
}
}
@@ -1,36 +0,0 @@
package at.mocode.clients.shared.presentation.actions
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
sealed class AppAction {
// Auth Actions
sealed class Auth : AppAction() {
data class LoginStart(val username: String, val password: String) : Auth()
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
data class LoginFailure(val error: String) : Auth()
object Logout : Auth()
data class RefreshToken(val newToken: AuthToken) : Auth()
}
// Navigation Actions
sealed class Navigation : AppAction() {
data class NavigateTo(val route: String) : Navigation()
object NavigateBack : Navigation()
data class UpdateHistory(val route: String) : Navigation()
}
// UI Actions
sealed class UI : AppAction() {
object ToggleDarkMode : UI()
data class SetLoading(val isLoading: Boolean) : UI()
data class ShowNotification(val notification: at.mocode.clients.shared.presentation.state.Notification) : UI()
data class DismissNotification(val id: String) : UI()
}
// Network Actions
sealed class Network : AppAction() {
data class SetOnlineStatus(val isOnline: Boolean) : Network()
data class UpdateLastSync(val timestamp: String) : Network()
}
}
@@ -1,55 +0,0 @@
package at.mocode.clients.shared.presentation.state
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import kotlinx.serialization.Serializable
@Serializable
data class AppState(
val auth: AuthState = AuthState(),
val navigation: NavigationState = NavigationState(),
val ui: UiState = UiState(),
val network: NetworkState = NetworkState()
)
@Serializable
data class AuthState(
val isAuthenticated: Boolean = false,
val user: User? = null,
val token: AuthToken? = null,
val isLoading: Boolean = false,
val error: String? = null
)
@Serializable
data class NavigationState(
val currentRoute: String = "/",
val history: List<String> = emptyList(),
val canGoBack: Boolean = false
)
@Serializable
data class UiState(
val isDarkMode: Boolean = false,
val isLoading: Boolean = false,
val notifications: List<Notification> = emptyList()
)
@Serializable
data class NetworkState(
val isOnline: Boolean = true,
val lastSync: String? = null
)
@Serializable
data class Notification(
val id: String,
val title: String,
val message: String,
val type: NotificationType = NotificationType.INFO,
val timestamp: String
)
enum class NotificationType {
INFO, SUCCESS, WARNING, ERROR
}
@@ -1,137 +0,0 @@
package at.mocode.clients.shared.presentation.store
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class AppStore(
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow()
fun dispatch(action: AppAction) {
scope.launch {
val currentState = _state.value
val newState = reduce(currentState, action)
_state.value = newState
// Handle side effects
handleSideEffect(action, newState)
}
}
private fun reduce(currentState: AppState, action: AppAction): AppState {
return when (action) {
is AppAction.Auth -> currentState.copy(
auth = reduceAuth(currentState.auth, action)
)
is AppAction.Navigation -> currentState.copy(
navigation = reduceNavigation(currentState.navigation, action)
)
is AppAction.UI -> currentState.copy(
ui = reduceUI(currentState.ui, action)
)
is AppAction.Network -> currentState.copy(
network = reduceNetwork(currentState.network, action)
)
}
}
private fun reduceAuth(currentAuth: at.mocode.clients.shared.presentation.state.AuthState, action: AppAction.Auth): at.mocode.clients.shared.presentation.state.AuthState {
return when (action) {
is AppAction.Auth.LoginStart -> currentAuth.copy(
isLoading = true,
error = null
)
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
isAuthenticated = true,
user = action.user,
token = action.token,
isLoading = false,
error = null
)
is AppAction.Auth.LoginFailure -> currentAuth.copy(
isAuthenticated = false,
user = null,
token = null,
isLoading = false,
error = action.error
)
is AppAction.Auth.Logout -> at.mocode.clients.shared.presentation.state.AuthState()
is AppAction.Auth.RefreshToken -> currentAuth.copy(
token = action.newToken
)
}
}
private fun reduceNavigation(currentNav: at.mocode.clients.shared.presentation.state.NavigationState, action: AppAction.Navigation): at.mocode.clients.shared.presentation.state.NavigationState {
return when (action) {
is AppAction.Navigation.NavigateTo -> currentNav.copy(
currentRoute = action.route,
history = currentNav.history + currentNav.currentRoute,
canGoBack = true
)
is AppAction.Navigation.NavigateBack -> {
val newHistory = currentNav.history.dropLast(1)
currentNav.copy(
currentRoute = newHistory.lastOrNull() ?: "/",
history = newHistory,
canGoBack = newHistory.isNotEmpty()
)
}
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
currentRoute = action.route
)
}
}
private fun reduceUI(currentUI: at.mocode.clients.shared.presentation.state.UiState, action: AppAction.UI): at.mocode.clients.shared.presentation.state.UiState {
return when (action) {
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
isDarkMode = !currentUI.isDarkMode
)
is AppAction.UI.SetLoading -> currentUI.copy(
isLoading = action.isLoading
)
is AppAction.UI.ShowNotification -> currentUI.copy(
notifications = currentUI.notifications + action.notification
)
is AppAction.UI.DismissNotification -> currentUI.copy(
notifications = currentUI.notifications.filter { it.id != action.id }
)
}
}
private fun reduceNetwork(currentNetwork: at.mocode.clients.shared.presentation.state.NetworkState, action: AppAction.Network): at.mocode.clients.shared.presentation.state.NetworkState {
return when (action) {
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
isOnline = action.isOnline
)
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
lastSync = action.timestamp
)
}
}
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
when (action) {
is AppAction.Auth.LoginSuccess -> {
// Auto-save token to local storage
// TODO: Implement storage
}
is AppAction.Auth.Logout -> {
// Clear local storage
// TODO: Implement storage cleanup
}
else -> { /* No side effects */ }
}
}
fun cleanup() {
scope.cancel()
}
}
@@ -1,69 +0,0 @@
package at.mocode.clients.shared.presentation.store
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import at.mocode.clients.shared.presentation.actions.AppAction
import kotlinx.coroutines.Dispatchers
import kotlin.test.*
class AppStoreTest {
@Test
fun `store should be created successfully`() {
val store = AppStore(Dispatchers.Unconfined)
assertNotNull(store)
store.cleanup()
}
@Test
fun `auth actions should update state`() {
val store = AppStore(Dispatchers.Unconfined)
// Test login start action
store.dispatch(AppAction.Auth.LoginStart("testuser", "password"))
// Test login success
val user = User("1", "test", "test@example.com", "Test", "User")
val token = AuthToken("access", "refresh", 3600)
store.dispatch(AppAction.Auth.LoginSuccess(user, token))
// Test logout
store.dispatch(AppAction.Auth.Logout)
store.cleanup()
assertTrue(true) // Basic test to verify actions don't throw exceptions
}
@Test
fun `navigation actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.Navigation.NavigateTo("/dashboard"))
store.dispatch(AppAction.Navigation.NavigateBack)
store.cleanup()
assertTrue(true)
}
@Test
fun `ui actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.UI.ToggleDarkMode)
store.dispatch(AppAction.UI.SetLoading(true))
store.cleanup()
assertTrue(true)
}
@Test
fun `network actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.Network.SetOnlineStatus(false))
store.dispatch(AppAction.Network.UpdateLastSync("2024-01-01T12:00:00Z"))
store.cleanup()
assertTrue(true)
}
}
@@ -1,18 +0,0 @@
package at.mocode.clients.shared.test
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
expect fun runBlockingTest(block: suspend () -> Unit)
abstract class BaseTest {
@BeforeTest
fun setupTest() {
// Set up a common test environment
}
@AfterTest
fun teardownTest() {
// Cleanup test environment
}
}
@@ -1,5 +0,0 @@
package at.mocode.clients.shared.network
import kotlin.js.Date
actual fun currentTimeMillis(): Long = Date.now().toLong()
@@ -1,10 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.*
@OptIn(DelicateCoroutinesApi::class)
actual fun runBlockingTest(block: suspend () -> Unit) {
GlobalScope.promise {
block()
}
}
@@ -1,3 +0,0 @@
package at.mocode.clients.shared.network
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
@@ -1,9 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.test.*
actual fun runBlockingTest(block: suspend () -> Unit) {
runTest {
block()
}
}
@@ -1,11 +0,0 @@
package at.mocode.clients.shared.network
// WASM implementation using a simple counter-approach
// Since we don't have direct access to system time in WASM,
// we'll use a monotonic counter for relative timing
private var wasmTimeCounter: Long = 0L
actual fun currentTimeMillis(): Long {
wasmTimeCounter += 1
return wasmTimeCounter
}
@@ -1,11 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.*
@OptIn(DelicateCoroutinesApi::class, ExperimentalWasmJsInterop::class)
actual fun runBlockingTest(block: suspend () -> Unit) {
// WASM-JS uses the same approach as regular JS
GlobalScope.promise {
block()
}
}
+37
View File
@@ -141,6 +141,43 @@ services:
networks: networks:
- meldestelle-network - meldestelle-network
# --- CLIENTS: WEB APP (Kotlin/JS, no WASM) ---
web-app:
build:
context: .
dockerfile: dockerfiles/clients/web-app/Dockerfile
args:
GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21
NODE_VERSION: 22.21.0
NGINX_IMAGE_TAG: 1.28.0-alpine
WEB_BUILD_PROFILE: dev
container_name: meldestelle-web-app
restart: unless-stopped
ports:
- "4000:4000"
depends_on:
- api-gateway
networks:
- meldestelle-network
# --- CLIENTS: DESKTOP APP (VNC + noVNC) ---
desktop-app:
build:
context: .
dockerfile: dockerfiles/clients/desktop-app/Dockerfile
container_name: meldestelle-desktop-app
restart: unless-stopped
environment:
- API_BASE_URL=http://api-gateway:8081
ports:
- "5901:5901" # VNC
- "6080:6080" # noVNC
depends_on:
- api-gateway
networks:
- meldestelle-network
volumes: volumes:
postgres-data: postgres-data:
pgadmin-data: pgadmin-data:
+52 -8
View File
@@ -29,7 +29,7 @@ services:
- postgres - postgres
redis: redis:
image: redis:7-alpine image: redis:8.4-alpine
container_name: ${COMPOSE_PROJECT_NAME}-redis container_name: ${COMPOSE_PROJECT_NAME}-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -105,7 +105,7 @@ services:
- pgadmin - pgadmin
prometheus: prometheus:
image: prom/prometheus:v2.54.1 image: prom/prometheus:v3.7.3
container_name: ${COMPOSE_PROJECT_NAME}-prometheus container_name: ${COMPOSE_PROJECT_NAME}-prometheus
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -128,7 +128,7 @@ services:
- prometheus - prometheus
grafana: grafana:
image: grafana/grafana:11.3.0 image: grafana/grafana:12.3
container_name: ${COMPOSE_PROJECT_NAME}-grafana container_name: ${COMPOSE_PROJECT_NAME}-grafana
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -157,7 +157,7 @@ services:
# ========================================== # ==========================================
consul: consul:
image: hashicorp/consul:1.15 image: hashicorp/consul:1.22.1
container_name: ${COMPOSE_PROJECT_NAME}-consul container_name: ${COMPOSE_PROJECT_NAME}-consul
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -182,7 +182,7 @@ services:
GRADLE_VERSION: 9.1.0 GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21 JAVA_VERSION: 21
VERSION: 1.0.0 VERSION: 1.0.0
BUILD_DATE: "2025-11-25" BUILD_DATE: "2025-11-29"
container_name: ${COMPOSE_PROJECT_NAME}-gateway container_name: ${COMPOSE_PROJECT_NAME}-gateway
restart: no restart: no
ports: ports:
@@ -230,7 +230,7 @@ services:
GRADLE_VERSION: 9.1.0 GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21 JAVA_VERSION: 21
VERSION: 1.0.0 VERSION: 1.0.0
BUILD_DATE: "2025-11-21" BUILD_DATE: "2025-11-29"
container_name: ${COMPOSE_PROJECT_NAME}-ping-service container_name: ${COMPOSE_PROJECT_NAME}-ping-service
restart: no # "${RESTART_POLICY:-unless-stopped}" restart: no # "${RESTART_POLICY:-unless-stopped}"
ports: ports:
@@ -246,7 +246,7 @@ services:
SPRING_CLOUD_CONSUL_PORT: 8500 SPRING_CLOUD_CONSUL_PORT: 8500
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service
# --- DATENBANK VERBINDUNG (Das hat gefehlt!) --- # --- DATENBANK VERBINDUNG ---
# Wir nutzen die Container-Namen aus deiner .env Variable # Wir nutzen die Container-Namen aus deiner .env Variable
SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB} SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
@@ -254,7 +254,7 @@ services:
# WICHTIG: Wir wollen nur validieren, nichts erstellen. # WICHTIG: Wir wollen nur validieren, nichts erstellen.
SPRING_JPA_HIBERNATE_DDL_AUTO: validate SPRING_JPA_HIBERNATE_DDL_AUTO: validate
# --- REDIS (DAS HAT GEFEHLT!) --- # --- REDIS ---
# Wir nutzen den Service-Namen, genau wie bei Postgres # Wir nutzen den Service-Namen, genau wie bei Postgres
SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis
SPRING_DATA_REDIS_PORT: 6379 SPRING_DATA_REDIS_PORT: 6379
@@ -270,6 +270,50 @@ services:
aliases: aliases:
- ping-service - ping-service
# ==========================================
# CLIENT APPLICATIONS
# ==========================================
web-app:
build:
context: .
dockerfile: dockerfiles/clients/web-app/Dockerfile
args:
GRADLE_VERSION: ${DOCKER_GRADLE_VERSION:-9.1.0}
JAVA_VERSION: ${DOCKER_JAVA_VERSION:-21}
NODE_VERSION: ${DOCKER_NODE_VERSION:-22.21.0}
NGINX_IMAGE_TAG: ${DOCKER_NGINX_VERSION:-1.28.0-alpine}
WEB_BUILD_PROFILE: ${WEB_BUILD_PROFILE:-dev}
container_name: ${COMPOSE_PROJECT_NAME}-web-app
restart: unless-stopped
ports:
- "${WEB_APP_PORT}"
depends_on:
api-gateway:
condition: service_started
networks:
meldestelle-network:
aliases:
- web-app
desktop-app:
build:
context: .
dockerfile: dockerfiles/clients/desktop-app/Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-desktop-app
restart: unless-stopped
environment:
API_BASE_URL: http://api-gateway:8081
ports:
- "${DESKTOP_APP_VNC_PORT}"
- "${DESKTOP_APP_NOVNC_PORT}"
depends_on:
api-gateway:
condition: service_started
networks:
meldestelle-network:
aliases:
- desktop-app
volumes: volumes:
postgres-data: postgres-data:
pgadmin-data: pgadmin-data:
-8
View File
@@ -1,8 +0,0 @@
# Optional Client Override – Web App
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn die Web-App abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# WEB_APP_PORT=4001
# NODE_ENV=development
# APP_TITLE=Meldestelle (Dev)
-8
View File
@@ -1,8 +0,0 @@
# Optional Infrastructure Override – API Gateway
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn das Gateway abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# GATEWAY_PORT=8081
# SPRING_PROFILES_ACTIVE=docker,keycloak
# LOGGING_LEVEL_ROOT=DEBUG
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Events Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Events-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8085
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Horses Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Horses-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8084
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Masterdata Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Masterdata-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8086
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Members Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Members-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8083
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true

Some files were not shown because too many files have changed in this diff Show More