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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-3833
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/jest": "^29.2.5",
|
|
||||||
"jest": "^29.3.1"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "jest"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"webpack-bundle-analyzer": "^4.10.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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': ''}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-118
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+288
-294
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-39
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-106
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-93
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -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?
|
||||||
|
}
|
||||||
+34
@@ -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
|
||||||
|
}
|
||||||
+19
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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)
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
actual object AuthCallbackParams {
|
||||||
|
actual fun parse(): CallbackParams? = null
|
||||||
|
}
|
||||||
+55
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-37
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-42
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-16
@@ -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 ?: "" }
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-21
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-249
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-124
@@ -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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+226
-222
@@ -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}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-29
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
-93
@@ -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
|
|
||||||
+148
-148
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+198
-198
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+156
-156
@@ -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.
|
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-237
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-78
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-61
@@ -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
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-71
@@ -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
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-90
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+149
-148
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-177
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
-232
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-250
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-198
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+29
-29
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -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")
|
||||||
|
}
|
||||||
-171
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+27
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-73
@@ -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)
|
||||||
|
}
|
||||||
+39
@@ -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
|
||||||
|
)
|
||||||
-27
@@ -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"
|
|
||||||
)
|
|
||||||
+8
@@ -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>
|
||||||
|
}
|
||||||
-194
@@ -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
|
|
||||||
}
|
|
||||||
-179
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-74
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-164
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-217
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-55
@@ -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
|
|
||||||
}
|
|
||||||
-137
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Vendored
-8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user