Merge pull request #18

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

* MP-19 Refactoring: Frontend Tabula Rasa

* MP-19 Refactoring: Frontend Tabula Rasa

* refactoring:

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

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

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

* MP-20 fixing: clients

* MP-20 fixing: clients
This commit is contained in:
StefanMo
2025-11-30 14:13:12 +01:00
committed by GitHub
parent 596a05b69c
commit 9ea2b74a81
254 changed files with 5485 additions and 15971 deletions
+5
View File
@@ -50,3 +50,8 @@ GATEWAY_SERVER_PORT=8081
# --- MICROSERVICES ---
PING_SERVICE_PORT=8082:8082
PING_DEBUG_PORT=5006:5006
# --- CLIENT APPLICATIONS ---
WEB_APP_PORT=4000:4000
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080
+7
View File
@@ -51,3 +51,10 @@ CONSUL_PORT=8500:8500
GATEWAY_PORT=8081
# Debug Port für IntelliJ (Remote JVM Debug)
GATEWAY_DEBUG_PORT=5005
# --- CLIENT APPLICATIONS ---
# Web-App (Kotlin/JS, kein WASM)
WEB_APP_PORT=4000:4000
# Desktop-App (VNC/noVNC)
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080
+8 -4
View File
@@ -44,7 +44,6 @@ kotlin {
webpackTask {
mainOutputFileName = "web-app.js"
output.libraryTarget = "commonjs2"
}
// Development Server konfigurieren
@@ -67,7 +66,10 @@ kotlin {
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
@@ -76,8 +78,8 @@ kotlin {
implementation(project(":clients:shared"))
implementation(project(":clients:shared:common-ui"))
implementation(project(":clients:shared:navigation"))
implementation(project(":clients:auth-feature"))
implementation(project(":clients:ping-feature"))
implementation(project(":clients:members-feature"))
// Compose Multiplatform
implementation(compose.runtime)
@@ -129,7 +131,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
"-Xskip-metadata-version-check" // Für bleeding-edge Versionen
"-Xskip-metadata-version-check", // Für bleeding-edge Versionen
// Suppress beta warning for expect/actual declarations used in this module
"-Xexpect-actual-classes"
)
}
}
-3833
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
{
"devDependencies": {
"@types/jest": "^29.2.5",
"jest": "^29.3.1"
},
"scripts": {
"test": "jest"
},
"dependencies": {
"webpack-bundle-analyzer": "^4.10.2"
}
}
+215 -64
View File
@@ -2,10 +2,23 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import at.mocode.clients.membersfeature.ProfileScreen
import at.mocode.clients.membersfeature.ProfileViewModel
import androidx.compose.runtime.collectAsState
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
fun MainApp() {
@@ -16,93 +29,231 @@ fun MainApp() {
) {
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
val authTokenManager = remember { AuthenticatedHttpClient.getAuthTokenManager() }
val pingViewModel = remember { PingViewModel() }
val scope = rememberCoroutineScope()
// 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 -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
is AppScreen.Login -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
is AppScreen.Ping -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
is AppScreen.Profile -> ProfileScreen(viewModel = remember { ProfileViewModel() })
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
fun DevelopmentScreen(onOpenProfile: () -> Unit) {
private fun WelcomeScreen(
authTokenManager: AuthTokenManager,
onOpenPing: () -> Unit,
onOpenLogin: () -> Unit,
onOpenProfile: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"🚀 Meldestelle Development Mode",
text = "Willkommen zur Meldestelle",
style = MaterialTheme.typography.headlineMedium
)
// Auth info
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"🌐 Backend Connectivity",
style = MaterialTheme.typography.titleMedium
)
if (authState.isAuthenticated) {
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
Spacer(Modifier.height(8.dp))
Button(onClick = onOpenProfile) { Text("Profil anzeigen") }
} else {
Text("Du bist nicht angemeldet.")
}
}
}
var testStatus by remember { mutableStateOf("Not tested") }
// 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(
horizontalArrangement = Arrangement.spacedBy(8.dp)
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)
) {
Button(onClick = { testStatus = "Testing Gateway..." }) {
Text("Test Gateway")
}
Button(onClick = { testStatus = "Testing Ping Service..." }) {
Text("Test Ping Service")
}
Button(onClick = onOpenProfile) {
Text("Open Profile")
}
}
Text("Status: $testStatus")
}
}
Text("Profil / Status", style = MaterialTheme.typography.headlineMedium)
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")
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") }
}
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")
}
}
}
}
@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)
}
}
+19 -3
View File
@@ -1,21 +1,37 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
window.onload = {
console.log("[WebApp] main() entered")
fun startApp() {
try {
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
val root = document.getElementById("ComposeTarget") as HTMLElement
console.log("[WebApp] ComposeTarget exists? ", (root != null))
ComposeViewport(root) {
MainApp()
}
// 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)
document.getElementById("root")?.innerHTML =
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() })
}
}
+18 -30
View File
@@ -2,38 +2,26 @@
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Meldestelle - Web Development</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
margin: 0;
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>
<title>Meldestelle - Web</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#0f172a">
</head>
<body>
<div id="root">
<canvas id="ComposeTarget"></canvas>
<div class="loading">🚀 Loading Meldestelle...</div>
</div>
<script src="web-app.js"></script>
<div id="ComposeTarget">
<div class="loading">Loading...</div>
</div>
<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>
</html>
+11 -1
View File
@@ -1,7 +1,8 @@
html, body {
height: 100vh;
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #fafafa;
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
}
@@ -10,3 +11,12 @@ html, body {
display: flex;
flex-direction: column;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #666;
}
+4 -2
View File
@@ -58,7 +58,8 @@ self.addEventListener('fetch', (event) => {
.then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') {
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;
})
@@ -83,7 +84,8 @@ self.addEventListener('fetch', (event) => {
.then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') {
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;
})
+1 -2
View File
@@ -1,6 +1,5 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import at.mocode.clients.app.App
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
@@ -8,6 +7,6 @@ import org.w3c.dom.HTMLElement
fun main() {
val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) {
App()
MainApp()
}
}
+12 -15
View File
@@ -16,20 +16,17 @@ if (process.env.ANALYZE_BUNDLE === 'true') {
}
}
// Weitere Optimierungen hinzufügen (erweitert bestehende config)
config.optimization = {
...config.optimization, // Behalte Kotlin/JS Optimierungen
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all'
}
}
}
};
// Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus.
// Diese Datei enthält nur einen Script-Tag zu "web-app.js" und wird NICHT
// vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime)
// würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet
// (Bildschirm bleibt auf "Loading...").
//
// Daher überschreiben wir config.optimization NICHT mehr mit splitChunks.
// Wenn später Chunking gewünscht ist, muss die index.html durch die generierte
// HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden.
//
// (Frühere splitChunks-Konfiguration wurde bewusst entfernt.)
// Development Server Konfiguration erweitern
if (config.devServer) {
@@ -44,7 +41,7 @@ if (config.devServer) {
target: 'http://localhost:8081',
changeOrigin: true,
secure: false,
pathRewrite: { '^/api': '' }
pathRewrite: {'^/api': ''}
}
]
}
+8 -2
View File
@@ -28,12 +28,16 @@ kotlin {
enabled = false
}
}
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
@@ -116,7 +120,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn"
"-opt-in=kotlin.RequiresOptIn",
// Suppress beta warning for expect/actual classes as per project decision
"-Xexpect-actual-classes"
)
}
}
@@ -1,11 +1,9 @@
package at.mocode.clients.authfeature
import at.mocode.clients.shared.AppConfig
import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.http.content.*
import kotlinx.serialization.Serializable
/**
@@ -31,11 +29,11 @@ data class LoginResponse(
*/
class AuthApiClient(
// 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
private val realm: String = AppConfig.KEYCLOAK_REALM,
private val realm: String = AppConstants.KEYCLOAK_REALM,
// 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)
private val clientSecret: String? = null
) {
@@ -86,6 +84,49 @@ class AuthApiClient(
}
}
/**
* Exchange an authorization code (PKCE) for tokens
*/
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try {
val response = client.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
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 = "Code-Exchange fehlgeschlagen: HTTP ${'$'}{response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Code-Exchange Fehler: ${'$'}{e.message}"
)
}
}
/**
* Refresh authentication token
*/
@@ -5,12 +5,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
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.ExperimentalEncodingApi
import kotlin.time.ExperimentalTime
@@ -1,6 +1,6 @@
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.plugins.contentnegotiation.*
import io.ktor.client.request.*
@@ -19,7 +19,7 @@ object AuthenticatedHttpClient {
/**
* 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 {
install(ContentNegotiation) {
json(Json {
@@ -2,14 +2,13 @@ package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.shared.AppConfig
import io.ktor.client.call.*
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
/**
* UI state for the login screen
@@ -98,7 +97,7 @@ class LoginViewModel(
viewModelScope.launch {
try {
val client = AuthenticatedHttpClient.create()
client.post("${AppConfig.GATEWAY_URL}/api/members/sync") {
client.post("${AppConstants.GATEWAY_URL}/api/members/sync") {
addAuthHeader()
}
} catch (_: Exception) {
@@ -0,0 +1,12 @@
package at.mocode.clients.authfeature.oauth
data class CallbackParams(val code: String, val state: String?)
expect object AuthCallbackParams {
/**
* Parse OAuth callback parameters from the current environment.
* - JS (web): reads window.location.search for `code` and `state` and removes them from the URL.
* - JVM (desktop): returns null.
*/
fun parse(): CallbackParams?
}
@@ -0,0 +1,34 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
data class PkceState(
val state: String,
val codeVerifier: String,
val codeChallenge: String,
val method: String = "S256"
)
object OAuthParams {
const val RESPONSE_TYPE = "code"
const val SCOPE = "openid"
}
/**
* expect/actual service to support PKCE across JS and JVM.
* For the desktop (JVM) target we currently do not start a browser flow,
* but we provide hashing to keep API parity.
*/
expect object OAuthPkceService {
/** Starts a PKCE auth attempt and stores transient state in memory. */
suspend fun startAuth(): PkceState
/** Returns currently active state if any (not persisted). */
fun current(): PkceState?
/** Clears transient state (after success/failure). */
fun clear()
/** Builds the authorize URL for the current state. */
fun buildAuthorizeUrl(state: PkceState, redirectUri: String = AppConstants.webRedirectUri()): String
}
@@ -0,0 +1,19 @@
package at.mocode.clients.authfeature.oauth
import kotlinx.browser.window
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? {
val search = window.location.search
if (search.isBlank()) return null
val params = js("new URLSearchParams(arguments[0])").unsafeCast<(String) -> dynamic>()(search)
val code = params.get("code") as String?
val state = params.get("state") as String?
return if (!code.isNullOrBlank()) {
// Clean up query params to avoid re-processing on recomposition
val url = window.location.origin + window.location.pathname
window.history.replaceState(null, "", url)
CallbackParams(code, state)
} else null
}
}
@@ -0,0 +1,81 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
import kotlinx.browser.window
import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Uint8Array
import kotlin.js.Promise
import kotlin.random.Random
private var currentPkce: PkceState? = null
private fun base64UrlFromBytes(bytes: ByteArray): String {
// Build binary string from bytes
val sb = StringBuilder(bytes.size)
for (b in bytes) sb.append(b.toInt().toChar())
val b64 = window.btoa(sb.toString())
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun base64UrlFromArrayBuffer(buf: ArrayBuffer): String {
val arr = Uint8Array(buf)
var binary = ""
val len = arr.length
for (i in 0 until len) {
val v = (arr.asDynamic()[i] as Number).toInt()
binary += fromCharCode(v)
}
val b64 = window.btoa(binary)
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun randomUrlSafe(length: Int): String {
val bytes = Random.Default.nextBytes(length)
// Use base64url for entropy; ensure URL-safe by replacing padding removed already
return base64UrlFromBytes(bytes)
}
private fun sha256(input: String): Promise<ArrayBuffer> {
val enc: dynamic = js("new TextEncoder()")
val data = enc.encode(input)
val subtle: dynamic = window.asDynamic().crypto.subtle
return subtle.digest("SHA-256", data) as Promise<ArrayBuffer>
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val challengeBuf = sha256(codeVerifier).await()
val codeChallenge = base64UrlFromArrayBuffer(challengeBuf)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkce = pkce
return pkce
}
actual fun current(): PkceState? = currentPkce
actual fun clear() {
currentPkce = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + encodeURIComponent(v) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
@Suppress("UnsafeCastFromDynamic")
private fun encodeURIComponent(value: String): String = js("encodeURIComponent")(value)
@Suppress("UnsafeCastFromDynamic")
private fun fromCharCode(code: Int): String = js("String.fromCharCode")(code)
@@ -0,0 +1,5 @@
package at.mocode.clients.authfeature.oauth
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? = null
}
@@ -0,0 +1,55 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
private var currentPkceJvm: PkceState? = null
private fun base64UrlNoPad(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
private fun randomUrlSafe(length: Int): String {
// Generate bytes and Base64 URL encode (will be > length due to encoding)
val rnd = SecureRandom()
val bytes = ByteArray(length)
rnd.nextBytes(bytes)
return base64UrlNoPad(bytes)
}
private fun sha256Base64Url(input: String): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return base64UrlNoPad(digest)
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val codeChallenge = sha256Base64Url(codeVerifier)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkceJvm = pkce
return pkce
}
actual fun current(): PkceState? = currentPkceJvm
actual fun clear() {
currentPkceJvm = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + java.net.URLEncoder.encode(v, Charsets.UTF_8) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
-87
View File
@@ -1,87 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser {
testTask { enabled = false }
}
}
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
}
sourceSets {
commonMain.dependencies {
// UI Kit
implementation(project(":clients:shared:common-ui"))
// Shared config/utilities
implementation(project(":clients:shared"))
// Authentication helpers (token + authenticated client)
implementation(project(":clients:auth-feature"))
// Compose
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// ViewModel lifecycle + compose helpers
implementation(libs.bundles.compose.common)
// HTTP + Kotlinx
implementation(libs.bundles.ktor.client.common)
implementation(libs.bundles.kotlinx.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn")
}
}
@@ -1,37 +0,0 @@
package at.mocode.clients.membersfeature
import at.mocode.clients.authfeature.AuthenticatedHttpClient
import at.mocode.clients.shared.AppConfig
import at.mocode.clients.membersfeature.model.MemberProfile
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
class MembersApiClient(
private val baseUrl: String = AppConfig.GATEWAY_URL
) {
private val client = AuthenticatedHttpClient.create()
suspend fun getMyProfile(): MemberProfile {
// Erwarteter Endpoint: GET /api/members/me
return client.get("$baseUrl/api/members/me") {
addAuthHeader()
}.body()
}
/**
* Optionaler Convenience-Call: Löst den Backend-Sync einmalig aus.
* Gibt true zurück, wenn der Call erfolgreich war (HTTP 2xx), sonst false.
*/
suspend fun syncProfile(): Boolean {
return try {
val response = client.post("$baseUrl/api/members/sync") {
addAuthHeader()
}
response.status.isSuccess()
} catch (_: Exception) {
false
}
}
}
@@ -1,63 +0,0 @@
package at.mocode.clients.membersfeature
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadProfile()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "👤 Mein Profil", style = MaterialTheme.typography.headlineSmall)
if (state.isLoading) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.errorMessage?.let { error ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Fehler", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(error)
Spacer(Modifier.height(8.dp))
Button(onClick = { viewModel.clearError() }) { Text("Schließen") }
}
}
}
state.profile?.let { profile ->
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = profile.fullName.ifBlank { "Unbekannt" }, style = MaterialTheme.typography.titleLarge)
profile.username?.let { Text("Benutzername: $it") }
profile.email?.let { Text("E-Mail: $it") }
if (profile.roles.isNotEmpty()) {
Text("Rollen: ${profile.roles.joinToString(", ")}")
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.loadProfile() }, enabled = !state.isLoading) {
Text("Neu laden")
}
}
}
}
@@ -1,42 +0,0 @@
package at.mocode.clients.membersfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.membersfeature.model.MemberProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: MemberProfile? = null,
val errorMessage: String? = null
)
class ProfileViewModel(
private val api: MembersApiClient = MembersApiClient()
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
viewModelScope.launch {
try {
val profile = api.getMyProfile()
_uiState.value = ProfileUiState(isLoading = false, profile = profile)
} catch (e: Exception) {
_uiState.value = ProfileUiState(
isLoading = false,
errorMessage = e.message ?: "Profil konnte nicht geladen werden"
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}
@@ -1,16 +0,0 @@
package at.mocode.clients.membersfeature.model
import kotlinx.serialization.Serializable
@Serializable
data class MemberProfile(
val id: String? = null,
val username: String? = null,
val email: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val roles: List<String> = emptyList()
) {
val fullName: String
get() = listOfNotNull(firstName, lastName).joinToString(" ").ifBlank { username ?: "" }
}
+5 -1
View File
@@ -28,12 +28,16 @@ kotlin {
enabled = false
}
}
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
@@ -4,7 +4,7 @@ import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
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.call.*
import io.ktor.client.plugins.contentnegotiation.*
@@ -13,7 +13,7 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class PingApiClient(
private val baseUrl: String = AppConfig.GATEWAY_URL
private val baseUrl: String = AppConstants.GATEWAY_URL
) : PingApi {
private val client = HttpClient {
@@ -8,7 +8,7 @@ import kotlinx.coroutines.delay
/**
* API-Client für Reitsport-Authentication-Testing
* Testet verschiedene Services mit rollenbasierten Tokens
* testet verschiedene Services mit rollenbasierten Tokens
*/
class ReitsportTestApi {
@@ -41,16 +41,20 @@ class ReitsportTestApi {
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))
}
@@ -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
+5
View File
@@ -58,6 +58,11 @@ kotlin {
implementation(libs.ktor.client.logging)
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)
implementation(compose.runtime)
implementation(compose.foundation)
+3 -1
View File
@@ -13,7 +13,8 @@ kotlin {
jvm()
js(IR) {
browser()
nodejs()
// nodejs()
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
@@ -21,6 +22,7 @@ kotlin {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
@@ -1,12 +1,11 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
enum class ButtonVariant {
@@ -79,6 +79,7 @@ fun MeldestelleTextField(
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
helperText != null -> {
Text(
text = helperText,
@@ -1,179 +1,5 @@
package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
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
)
}
}
}
// Legacy notification components removed due to dependency on old presentation layer.
// Intentionally left empty as part of cleanup. You can safely delete this file
// if no modules import it anymore.
@@ -1,232 +0,0 @@
package at.mocode.clients.shared.commonui.layout
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.commonui.screens.LoginScreenContainer
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
var showUserMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = "Meldestelle",
fontWeight = FontWeight.Bold
)
},
actions = {
// Notifications
if (appState.ui.notifications.isNotEmpty()) {
BadgedBox(
badge = {
Badge(
contentColor = MaterialTheme.colorScheme.onError,
containerColor = MaterialTheme.colorScheme.error
) {
Text(appState.ui.notifications.size.toString())
}
}
) {
IconButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("🔔")
}
}
} else {
IconButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("🔔")
}
}
// Theme toggle
IconButton(
onClick = { onDispatchAction(AppAction.UI.ToggleDarkMode) }
) {
Text(if (appState.ui.isDarkMode) "☀️" else "🌙")
}
// User menu
Box {
IconButton(
onClick = { showUserMenu = true }
) {
Text("👤")
}
DropdownMenu(
expanded = showUserMenu,
onDismissRequest = { showUserMenu = false }
) {
DropdownMenuItem(
text = {
Column {
Text(
text = appState.auth.user?.firstName ?: "User",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = appState.auth.user?.email ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
showUserMenu = false
onNavigateTo("/profile")
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Settings") },
onClick = {
showUserMenu = false
onNavigateTo("/settings")
}
)
DropdownMenuItem(
text = { Text("Help") },
onClick = {
showUserMenu = false
onNavigateTo("/help")
}
)
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
text = "Logout",
color = MaterialTheme.colorScheme.error
)
},
onClick = {
showUserMenu = false
onDispatchAction(AppAction.Auth.Logout)
}
)
}
}
}
)
},
bottomBar = {
if (appState.ui.notifications.isNotEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${appState.ui.notifications.size} notification(s)",
style = MaterialTheme.typography.bodySmall
)
TextButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("View All")
}
}
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Loading overlay
if (appState.ui.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
FullScreenLoading("Loading...")
}
} else {
content()
}
}
}
}
@Composable
fun AuthenticatedLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
if (appState.auth.isAuthenticated) {
MainLayout(
appState = appState,
onDispatchAction = onDispatchAction,
onNavigateTo = onNavigateTo,
content = content,
modifier = modifier
)
} else {
// Show login screen if not authenticated
LoginScreenContainer(
authState = appState.auth,
onDispatchAction = onDispatchAction,
modifier = modifier
)
}
}
@Composable
fun ResponsiveLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
// Simple responsive design - could be enhanced with actual screen size detection
val isCompact = remember { mutableStateOf(false) }
AuthenticatedLayout(
appState = appState,
onDispatchAction = onDispatchAction,
onNavigateTo = onNavigateTo,
content = { content(isCompact.value) },
modifier = modifier
)
}
@@ -1,250 +0,0 @@
package at.mocode.clients.shared.commonui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val user = appState.auth.user
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Welcome Header
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Text(
text = "Welcome back, ${user?.firstName ?: "User"}!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Here's what's happening in your Meldestelle dashboard",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
// Quick Actions
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PrimaryButton(
text = "New Report",
onClick = { onNavigateTo("/reports/new") },
modifier = Modifier.weight(1f)
)
SecondaryButton(
text = "View Reports",
onClick = { onNavigateTo("/reports") },
modifier = Modifier.weight(1f)
)
}
// Statistics Cards
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
title = "Total Reports",
value = "142",
modifier = Modifier.weight(1f)
)
StatisticCard(
title = "Open Issues",
value = "23",
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
title = "Resolved",
value = "119",
modifier = Modifier.weight(1f)
)
StatisticCard(
title = "This Month",
value = "18",
modifier = Modifier.weight(1f)
)
}
// Recent Activity
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ActivityItem(
title = "Report #1234 updated",
subtitle = "Status changed to 'In Progress'",
timestamp = "2 hours ago"
)
HorizontalDivider()
ActivityItem(
title = "New report submitted",
subtitle = "Report #1235 - Urgent priority",
timestamp = "4 hours ago"
)
HorizontalDivider()
ActivityItem(
title = "Report #1230 resolved",
subtitle = "Issue successfully closed",
timestamp = "1 day ago"
)
}
}
// Connection Status
if (!appState.network.isOnline) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Offline Mode",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Some features may be limited",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
@Composable
private fun StatisticCard(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ActivityItem(
title: String,
subtitle: String,
timestamp: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
@@ -1,198 +0,0 @@
package at.mocode.clients.shared.commonui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.presentation.actions.AppAction
import at.mocode.clients.shared.presentation.state.AuthState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
authState: AuthState,
onLoginClick: (String, String) -> Unit,
onNavigateToRegister: () -> Unit = {},
onForgotPassword: () -> Unit = {},
modifier: Modifier = Modifier
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var usernameError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
val focusManager = LocalFocusManager.current
// Validate form
val isFormValid = username.isNotBlank() && password.isNotBlank() &&
usernameError == null && passwordError == null
fun validateUsername(value: String) {
usernameError = FormValidation.validateRequired(value, "Username")
}
fun validatePassword(value: String) {
passwordError = FormValidation.validatePassword(value)
}
fun handleLogin() {
validateUsername(username)
validatePassword(password)
if (isFormValid) {
onLoginClick(username.trim(), password)
}
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Text(
text = "Meldestelle",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "Sign in to your account",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Username Field
MeldestelleTextField(
value = username,
onValueChange = {
username = it
if (usernameError != null) validateUsername(it)
},
label = "Username",
placeholder = "Enter your username",
isError = usernameError != null,
errorMessage = usernameError,
enabled = !authState.isLoading,
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
// Password Field
MeldestellePasswordField(
value = password,
onValueChange = {
password = it
if (passwordError != null) validatePassword(it)
},
label = "Password",
placeholder = "Enter your password",
isError = passwordError != null,
errorMessage = passwordError,
enabled = !authState.isLoading,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (isFormValid) handleLogin()
}
)
)
// Error display
authState.error?.let { errorMessage ->
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(8.dp))
// Login Button
PrimaryButton(
text = "Sign In",
onClick = ::handleLogin,
enabled = isFormValid && !authState.isLoading,
isLoading = authState.isLoading,
fullWidth = true
)
// Forgot Password
TextButton(
onClick = onForgotPassword,
enabled = !authState.isLoading
) {
Text("Forgot Password?")
}
HorizontalDivider()
// Register Link
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Don't have an account?",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
TextButton(
onClick = onNavigateToRegister,
enabled = !authState.isLoading
) {
Text("Sign Up")
}
}
}
}
}
}
@Composable
fun LoginScreenContainer(
authState: AuthState,
onDispatchAction: (AppAction) -> Unit,
onNavigateToRegister: () -> Unit = {},
onForgotPassword: () -> Unit = {},
modifier: Modifier = Modifier
) {
LoginScreen(
authState = authState,
onLoginClick = { username, password ->
onDispatchAction(AppAction.Auth.LoginStart(username, password))
},
onNavigateToRegister = onNavigateToRegister,
onForgotPassword = onForgotPassword,
modifier = modifier
)
}
@@ -18,12 +18,14 @@ kotlin {
js {
browser()
binaries.executable()
}
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
@@ -5,4 +5,5 @@ sealed class AppScreen {
data object Login : AppScreen()
data object Ping : AppScreen()
data object Profile : AppScreen()
data object AuthCallback : AppScreen()
}
@@ -1,17 +0,0 @@
package at.mocode.clients.shared
/**
* Zentrale App-Konfiguration für alle Client-Module.
* Hinweis: Diese Werte sind zentrale Defaults für DEV. Für PROD sollten sie
* via Build-Injektion (Gradle/ENV) überschrieben werden. Ein einfaches
* BuildConfig-Setup kann später ergänzt werden.
*/
object AppConfig {
// Gateway Basis-URL (API Gateway)
const val GATEWAY_URL: String = "http://localhost:8081"
// Keycloak Konfiguration
const val KEYCLOAK_URL: String = "http://localhost:8180"
const val KEYCLOAK_REALM: String = "meldestelle"
const val KEYCLOAK_CLIENT_ID: String = "meldestelle-frontend"
}
@@ -0,0 +1,12 @@
package at.mocode.clients.shared.core
data class AppConfig(
val gatewayUrl: String,
val isDebug: Boolean
)
// Standard-Config für Local Development
val devConfig = AppConfig(
gatewayUrl = "http://localhost:8081",
isDebug = true
)
@@ -0,0 +1,47 @@
package at.mocode.clients.shared.core
/**
* Shared application configuration constants for clients.
* These defaults target local development environments.
*/
object AppConstants {
// Gateway base URL (reverse proxy / API gateway)
const val GATEWAY_URL: String = "http://localhost:8081"
// Keycloak configuration
const val KEYCLOAK_URL: String = "http://localhost:8180"
const val KEYCLOAK_REALM: String = "meldestelle"
// Use public client configured in realm import: `web-app`
const val KEYCLOAK_CLIENT_ID: String = "web-app"
// Default redirect URI for web PKCE flow (served by Nginx in web image)
// We use the root path so Keycloak can redirect back to /?code=...
fun webRedirectUri(): String = "http://localhost:4000/"
fun registerUrl(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/registrations?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
encode(
webRedirectUri()
)
}"
fun loginUrl(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
encode(
webRedirectUri()
)
}"
fun authorizeEndpoint(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth"
fun tokenEndpoint(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token"
fun desktopDownloadUrl(): String = "http://localhost:4000/downloads/"
// Helper to URL-encode values (very small percent-encoding sufficient for URIs here)
private fun encode(value: String): String =
value.replace("://", ":%2F%2F").replace("/", "%2F").replace(":", "%3A")
}
@@ -1,171 +0,0 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import at.mocode.clients.shared.domain.models.ApiResponse
import at.mocode.clients.shared.network.HttpClientConfig
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
/**
* Authentication repository handling all authentication-related operations
* with Keycloak integration.
*/
class AuthRepository(
private val baseUrl: String = "http://localhost:8080",
private val keycloakUrl: String = "http://localhost:8180",
private val realm: String = "meldestelle",
private val clientId: String = "meldestelle-client"
) : Repository {
private val httpClient: HttpClient = HttpClientConfig.createClient(baseUrl)
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class KeycloakTokenResponse(
val access_token: String,
val refresh_token: String,
val expires_in: Long,
val token_type: String = "Bearer"
)
/**
* Authenticate user with username and password via Keycloak
*/
suspend fun login(username: String, password: String): RepositoryResult<AuthToken> {
return try {
val response = httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
formParameters = Parameters.build {
append("grant_type", "password")
append("client_id", clientId)
append("username", username)
append("password", password)
}
).body<KeycloakTokenResponse>()
val authToken = AuthToken(
accessToken = response.access_token,
refreshToken = response.refresh_token,
expiresIn = response.expires_in,
tokenType = response.token_type
)
RepositoryResult.Success(authToken)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "LOGIN_FAILED",
message = "Login failed: ${e.message}"
)
)
}
}
/**
* Refresh authentication token
*/
suspend fun refreshToken(refreshToken: String): RepositoryResult<AuthToken> {
return try {
val response = httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
formParameters = Parameters.build {
append("grant_type", "refresh_token")
append("client_id", clientId)
append("refresh_token", refreshToken)
}
).body<KeycloakTokenResponse>()
val authToken = AuthToken(
accessToken = response.access_token,
refreshToken = response.refresh_token,
expiresIn = response.expires_in,
tokenType = response.token_type
)
RepositoryResult.Success(authToken)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "TOKEN_REFRESH_FAILED",
message = "Token refresh failed: ${e.message}"
)
)
}
}
/**
* Get current user information using access token
*/
suspend fun getCurrentUser(accessToken: String): RepositoryResult<User> {
return try {
val response = httpClient.get("$baseUrl/api/auth/me") {
header("Authorization", "Bearer $accessToken")
}.body<ApiResponse<User>>()
response.toRepositoryResult()
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "USER_INFO_FAILED",
message = "Failed to get user info: ${e.message}"
)
)
}
}
/**
* Logout user by invalidating tokens
*/
suspend fun logout(refreshToken: String): RepositoryResult<Unit> {
return try {
httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/logout",
formParameters = Parameters.build {
append("client_id", clientId)
append("refresh_token", refreshToken)
}
)
RepositoryResult.Success(Unit)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "LOGOUT_FAILED",
message = "Logout failed: ${e.message}"
)
)
}
}
/**
* Check if token is still valid
*/
suspend fun validateToken(accessToken: String): RepositoryResult<Boolean> {
return try {
val response = httpClient.get("$baseUrl/api/auth/validate") {
header("Authorization", "Bearer $accessToken")
}.body<ApiResponse<Boolean>>()
response.toRepositoryResult()
} catch (e: Exception) {
RepositoryResult.Success(false) // Token is invalid
}
}
/**
* Cleanup resources
*/
fun close() {
httpClient.close()
}
}
@@ -0,0 +1,27 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.model.PingData
import at.mocode.clients.shared.domain.model.Resource
import at.mocode.clients.shared.domain.repository.PingRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
class PingRepositoryImpl(
private val httpClient: HttpClient
) : PingRepository {
override suspend fun checkSystemStatus(): Resource<PingData> {
return try {
// Der HttpClient hat die BaseURL schon konfiguriert (siehe NetworkModule)
val response = httpClient.get("/api/ping/simple").body<PingData>()
Resource.Success(response)
} catch (e: Exception) {
// Hier fangen wir Netzwerkfehler ab und machen sie "hübsch" für die UI
Resource.Error(
message = "Verbindung fehlgeschlagen: ${e.message ?: "Unbekannter Fehler"}",
code = "NETWORK_ERROR"
)
}
}
}
@@ -1,73 +0,0 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.models.ApiResponse
import at.mocode.clients.shared.domain.models.ApiError
/**
* Base repository interface defining common operations and patterns
* for data access across the application.
*/
interface Repository
/**
* Result wrapper for repository operations to handle success/error states
*/
sealed class RepositoryResult<out T> {
data class Success<T>(val data: T) : RepositoryResult<T>()
data class Error(val error: ApiError) : RepositoryResult<Nothing>()
data class Loading(val message: String = "Loading...") : RepositoryResult<Nothing>()
fun isSuccess(): Boolean = this is Success
fun isError(): Boolean = this is Error
fun isLoading(): Boolean = this is Loading
fun getOrNull(): T? = when (this) {
is Success -> data
else -> null
}
fun getErrorOrNull(): ApiError? = when (this) {
is Error -> error
else -> null
}
}
/**
* Extension function to convert ApiResponse to RepositoryResult
*/
fun <T> ApiResponse<T>.toRepositoryResult(): RepositoryResult<T> {
return if (success && data != null) {
RepositoryResult.Success(data)
} else {
RepositoryResult.Error(
error ?: ApiError(
code = "UNKNOWN_ERROR",
message = "Unknown error occurred"
)
)
}
}
/**
* Extension function to handle repository results with callbacks
*/
inline fun <T> RepositoryResult<T>.onSuccess(action: (T) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Success) {
action(data)
}
return this
}
inline fun <T> RepositoryResult<T>.onError(action: (ApiError) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Error) {
action(error)
}
return this
}
inline fun <T> RepositoryResult<T>.onLoading(action: (String) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Loading) {
action(message)
}
return this
}
@@ -0,0 +1,50 @@
package at.mocode.clients.shared.di
import at.mocode.clients.shared.core.AppConfig
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.koin.dsl.module
val networkModule = module {
// 1. JSON Konfiguration (Global verfügbar)
single {
Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
}
// 2. HttpClient (Singleton)
single {
val config = get<AppConfig>()
val jsonConfig = get<Json>()
HttpClient {
// Standard-URL setzen
defaultRequest {
url(config.gatewayUrl)
contentType(ContentType.Application.Json)
}
install(ContentNegotiation) {
json(jsonConfig)
}
install(Logging) {
level = if (config.isDebug) LogLevel.INFO else LogLevel.NONE
logger = Logger.DEFAULT
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 10000
}
}
}
}
@@ -0,0 +1,23 @@
package at.mocode.clients.shared.di
import at.mocode.clients.shared.core.devConfig
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module
// Das Modul für die Config
val configModule = module {
single { devConfig } // Später können wir hier PROD/DEV umschalten
}
// Alle Module zusammen
val sharedModules = listOf(
configModule,
networkModule
)
// Helper zum Starten von Koin (wird von der App aufgerufen)
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(sharedModules)
}
@@ -0,0 +1,39 @@
package at.mocode.clients.shared.domain.model
import kotlinx.serialization.Serializable
/**
* Generischer Wrapper für API-Antworten.
*/
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null
)
@Serializable
data class ApiError(
val code: String,
val message: String
)
/**
* Das Ergebnis eines Repository-Aufrufs.
* Die UI kennt nur das hier, keine HTTP-Exceptions!
*/
sealed class Resource<out T> {
data class Success<T>(val data: T) : Resource<T>()
data class Error(val message: String, val code: String? = null) : Resource<Nothing>()
data object Loading : Resource<Nothing>()
}
/**
* Datenmodell für den Ping.
*/
@Serializable
data class PingData(
val status: String,
val timestamp: String,
val service: String
)
@@ -1,27 +0,0 @@
package at.mocode.clients.shared.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null,
val timestamp: String,
val correlationId: String? = null
)
@Serializable
data class ApiError(
val code: String,
val message: String,
val details: Map<String, String> = emptyMap()
)
@Serializable
data class HealthResponse(
val status: String,
val timestamp: String,
val service: String,
val healthy: Boolean
)
@@ -1,22 +0,0 @@
package at.mocode.clients.shared.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val username: String,
val email: String,
val firstName: String,
val lastName: String,
val roles: Set<String> = emptySet(),
val isActive: Boolean = true
)
@Serializable
data class AuthToken(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long,
val tokenType: String = "Bearer"
)
@@ -0,0 +1,8 @@
package at.mocode.clients.shared.domain.repository
import at.mocode.clients.shared.domain.model.PingData
import at.mocode.clients.shared.domain.model.Resource
interface PingRepository {
suspend fun checkSystemStatus(): Resource<PingData>
}
@@ -1,194 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.store.AppStore
/**
* Deep link handling for the application
*/
class DeepLinkHandler(
private val navigationManager: NavigationManager,
private val store: AppStore
) {
/**
* Deep link configuration
*/
data class DeepLinkConfig(
val scheme: String = "meldestelle",
val host: String = "app",
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
)
private val config = DeepLinkConfig()
/**
* Handle a deep link URL
*/
fun handleDeepLink(url: String): Boolean {
return try {
val parsedLink = parseDeepLink(url)
if (parsedLink != null) {
processDeepLink(parsedLink)
true
} else {
false
}
} catch (e: Exception) {
// Log error in real implementation
false
}
}
/**
* Parse deep link URL into components
*/
private fun parseDeepLink(url: String): DeepLink? {
return when {
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
else -> null
}
}
/**
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
*/
private fun parseCustomSchemeLink(url: String): DeepLink? {
val withoutScheme = url.removePrefix("${config.scheme}://")
val parts = withoutScheme.split("/")
if (parts.isEmpty() || parts[0] != config.host) {
return null
}
val path = "/" + parts.drop(1).joinToString("/")
val route = if (path == "/") Routes.HOME else path
return DeepLink(
type = DeepLinkType.CUSTOM_SCHEME,
route = route,
params = RouteUtils.parseRouteParams(route),
originalUrl = url
)
}
/**
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
*/
private fun parseWebLink(url: String): DeepLink? {
// Simple URL parsing - in real implementation use proper URL parser
val urlParts = url.split("/")
if (urlParts.size < 3) return null
val domain = urlParts[2]
if (!config.allowedDomains.contains(domain)) {
return null
}
val path = "/" + urlParts.drop(3).joinToString("/")
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
return DeepLink(
type = DeepLinkType.WEB_LINK,
route = route,
params = RouteUtils.parseRouteParams(route),
originalUrl = url
)
}
/**
* Process a parsed deep link
*/
private fun processDeepLink(deepLink: DeepLink) {
val authState = store.state.value.auth
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
// Check if route requires authentication
if (RouteUtils.requiresAuth(cleanRoute)) {
if (!authState.isAuthenticated) {
// Save the intended route and redirect to log in
saveIntendedRoute(deepLink.route)
navigationManager.navigateTo(Routes.Auth.LOGIN)
return
}
}
// Check if route requires admin privileges
if (RouteUtils.requiresAdmin(cleanRoute)) {
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
if (!hasAdminRole) {
// Redirect to unauthorized or home
navigationManager.navigateTo(Routes.HOME)
return
}
}
// Navigate to the route
navigationManager.navigateTo(deepLink.route)
}
/**
* Save the intended route for after authentication
*/
private fun saveIntendedRoute(route: String) {
// In real implementation, save to persistent storage
// For now; we'll store it in a simple variable
intendedRoute = route
}
/**
* Get and clear the intended route
*/
fun getAndClearIntendedRoute(): String? {
val route = intendedRoute
intendedRoute = null
return route
}
/**
* Check if there's a pending intended route
*/
fun hasIntendedRoute(): Boolean = intendedRoute != null
/**
* Generate a deep link for a route
*/
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
return if (useCustomScheme) {
"${config.scheme}://${config.host}$route"
} else {
"https://${config.allowedDomains.first()}$route"
}
}
/**
* Validate if a route is valid for deep linking
*/
fun isValidDeepLinkRoute(route: String): Boolean {
return RouteUtils.isValidRoute(route) &&
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
route != Routes.Auth.LOGIN
}
companion object {
private var intendedRoute: String? = null
}
}
/**
* Deep link data class
*/
data class DeepLink(
val type: DeepLinkType,
val route: String,
val params: Map<String, String>,
val originalUrl: String
)
/**
* Types of deep links
*/
enum class DeepLinkType {
CUSTOM_SCHEME, // meldestelle://app/route
WEB_LINK // https://meldestelle.com/route
}
@@ -1,179 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.actions.AppAction
import at.mocode.clients.shared.presentation.store.AppStore
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Navigation manager for handling routing and navigation logic
*/
class NavigationManager(
private val store: AppStore
) {
/**
* Current route as a flow
*/
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
/**
* Navigation history as a flow
*/
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
/**
* Can go back flag as a flow
*/
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
/**
* Navigate to a specific route
*/
fun navigateTo(route: String) {
store.dispatch(AppAction.Navigation.NavigateTo(route))
}
/**
* Navigate back to the previous route
*/
fun navigateBack() {
store.dispatch(AppAction.Navigation.NavigateBack)
}
/**
* Replace current route without adding to history
*/
fun replaceRoute(route: String) {
store.dispatch(AppAction.Navigation.UpdateHistory(route))
}
/**
* Clear navigation history and navigate to the route
*/
fun navigateAndClearHistory(route: String) {
// First clear by replacing with the new route
store.dispatch(AppAction.Navigation.UpdateHistory(route))
}
/**
* Get current route value (non-reactive)
*/
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
/**
* Check if we can navigate back
*/
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
}
/**
* Route definitions for the application
*/
object Routes {
const val HOME = "/"
const val LOGIN = "/login"
const val DASHBOARD = "/dashboard"
const val PROFILE = "/profile"
const val SETTINGS = "/settings"
const val PING = "/ping"
// Auth-related routes
object Auth {
const val LOGIN = "/auth/login"
const val LOGOUT = "/auth/logout"
const val REGISTER = "/auth/register"
const val FORGOT_PASSWORD = "/auth/forgot-password"
}
// Admin routes
object Admin {
const val DASHBOARD = "/admin/dashboard"
const val USERS = "/admin/users"
const val SETTINGS = "/admin/settings"
}
// Feature routes
object Features {
const val PING = "/features/ping"
const val REPORTS = "/features/reports"
const val NOTIFICATIONS = "/features/notifications"
}
}
/**
* Route validation and utilities
*/
object RouteUtils {
/**
* Check if a route requires authentication
*/
fun requiresAuth(route: String): Boolean {
return when {
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
route == Routes.HOME -> false
route == Routes.LOGIN -> false
else -> true
}
}
/**
* Check if a route is for admin only
*/
fun requiresAdmin(route: String): Boolean {
return route.startsWith("/admin/")
}
/**
* Get the default route for authenticated users
*/
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
/**
* Get the default route for unauthenticated users
*/
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
/**
* Validate route format
*/
fun isValidRoute(route: String): Boolean {
return route.startsWith("/") && route.isNotBlank()
}
/**
* Parse route parameters (simple implementation)
*/
fun parseRouteParams(route: String): Map<String, String> {
val params = mutableMapOf<String, String>()
// Simple query parameter parsing
if (route.contains("?")) {
val parts = route.split("?")
if (parts.size == 2) {
val queryParams = parts[1].split("&")
queryParams.forEach { param ->
val keyValue = param.split("=")
if (keyValue.size == 2) {
params[keyValue[0]] = keyValue[1]
}
}
}
}
return params
}
/**
* Get clean route without parameters
*/
fun getCleanRoute(route: String): String {
return if (route.contains("?")) {
route.split("?")[0]
} else {
route
}
}
}
@@ -1,74 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.state.NavigationState
import kotlinx.coroutines.flow.Flow
/**
* Interface für das Persistieren von Navigation State
*/
interface NavigationPersistence {
suspend fun saveNavigationState(state: NavigationState)
fun getNavigationState(): Flow<NavigationState?>
suspend fun clearNavigationState()
}
/**
* Default implementation ohne echte Persistierung (In-Memory)
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
*/
class DefaultNavigationPersistence : NavigationPersistence {
private var currentState: NavigationState? = null
override suspend fun saveNavigationState(state: NavigationState) {
currentState = state
}
override fun getNavigationState(): Flow<NavigationState?> {
return kotlinx.coroutines.flow.flowOf(currentState)
}
override suspend fun clearNavigationState() {
currentState = null
}
}
/**
* Navigation History Manager mit Persistierung
*/
class NavigationHistoryManager(
private val persistence: NavigationPersistence
) {
companion object {
private const val MAX_HISTORY_SIZE = 50
}
suspend fun saveRoute(route: String, history: List<String>) {
val state = NavigationState(
currentRoute = route,
history = history.takeLast(MAX_HISTORY_SIZE),
canGoBack = history.isNotEmpty()
)
persistence.saveNavigationState(state)
}
fun getPersistedState() = persistence.getNavigationState()
suspend fun clear() = persistence.clearNavigationState()
/**
* Optimiert die History für bessere Performance
*/
private fun optimizeHistory(history: List<String>): List<String> {
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
return history
.fold(emptyList<String>()) { acc, route ->
if (acc.lastOrNull() != route) acc + route else acc
}
.takeLast(MAX_HISTORY_SIZE)
}
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
saveRoute(newRoute, optimizedHistory.dropLast(1))
}
}
@@ -1,27 +0,0 @@
package at.mocode.clients.shared.network
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object HttpClientConfig {
fun createClient(
baseUrl: String = "http://localhost:8080"
): HttpClient = HttpClient {
// Content negotiation with JSON (based on PingApiClient pattern)
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
return createClient(baseUrl)
}
}
@@ -1,164 +0,0 @@
package at.mocode.clients.shared.network
import at.mocode.clients.shared.domain.models.ApiError
import io.ktor.client.network.sockets.*
import io.ktor.client.plugins.*
import kotlinx.io.IOException
/**
* Custom exceptions for network operations
*/
sealed class NetworkException(
message: String,
cause: Throwable? = null,
val apiError: ApiError
) : Exception(message, cause) {
class ConnectionException(
message: String = "Connection failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "CONNECTION_ERROR",
message = message,
details = mapOf("type" to "network_connectivity")
)
)
class TimeoutException(
message: String = "Request timed out",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "TIMEOUT_ERROR",
message = message,
details = mapOf("type" to "request_timeout")
)
)
class ServerException(
statusCode: Int,
message: String = "Server error",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "SERVER_ERROR",
message = message,
details = mapOf(
"type" to "server_error",
"status_code" to statusCode.toString()
)
)
)
class ClientException(
statusCode: Int,
message: String = "Client error",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "CLIENT_ERROR",
message = message,
details = mapOf(
"type" to "client_error",
"status_code" to statusCode.toString()
)
)
)
class AuthenticationException(
message: String = "Authentication failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "AUTHENTICATION_ERROR",
message = message,
details = mapOf("type" to "authentication_failure")
)
)
class AuthorizationException(
message: String = "Authorization failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "AUTHORIZATION_ERROR",
message = message,
details = mapOf("type" to "authorization_failure")
)
)
class UnknownException(
message: String = "Unknown error occurred",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "UNKNOWN_ERROR",
message = message,
details = mapOf("type" to "unknown_error")
)
)
}
/**
* Extension function to convert various exceptions to NetworkException
*/
fun Throwable.toNetworkException(): NetworkException {
return when (this) {
is ConnectTimeoutException -> NetworkException.TimeoutException(
message = "Connection timeout: ${this.message}",
cause = this
)
is SocketTimeoutException -> NetworkException.TimeoutException(
message = "Socket timeout: ${this.message}",
cause = this
)
is ResponseException -> when (this.response.status.value) {
401 -> NetworkException.AuthenticationException(
message = "Authentication required",
cause = this
)
403 -> NetworkException.AuthorizationException(
message = "Access forbidden",
cause = this
)
in 400..499 -> NetworkException.ClientException(
statusCode = this.response.status.value,
message = "Client error: ${this.message}",
cause = this
)
in 500..599 -> NetworkException.ServerException(
statusCode = this.response.status.value,
message = "Server error: ${this.message}",
cause = this
)
else -> NetworkException.UnknownException(
message = "HTTP error: ${this.message}",
cause = this
)
}
is IOException -> NetworkException.ConnectionException(
message = "Network connection failed: ${this.message}",
cause = this
)
is NetworkException -> this
else -> NetworkException.UnknownException(
message = "Unexpected error: ${this.message}",
cause = this
)
}
}
@@ -1,217 +0,0 @@
package at.mocode.clients.shared.network
import at.mocode.clients.shared.data.repository.RepositoryResult
import at.mocode.clients.shared.domain.models.ApiError
import kotlinx.coroutines.delay
// Using platform-agnostic timestamp handling
/**
* Simple timestamp provider for multiplatform compatibility
*/
expect fun currentTimeMillis(): Long
/**
* Network utilities for handling retry logic and resilience
*/
object NetworkUtils {
/**
* Retry configuration for network operations
*/
data class RetryConfig(
val maxAttempts: Int = 3,
val initialDelayMs: Long = 1000L,
val maxDelayMs: Long = 10000L,
val backoffMultiplier: Double = 2.0,
val retryableExceptions: Set<String> = setOf(
"CONNECTION_ERROR",
"TIMEOUT_ERROR",
"SERVER_ERROR"
)
)
/**
* Execute operation with retry logic
*/
suspend fun <T> withRetry(
config: RetryConfig = RetryConfig(),
operation: suspend () -> RepositoryResult<T>
): RepositoryResult<T> {
var lastError: ApiError? = null
var currentDelay = config.initialDelayMs
repeat(config.maxAttempts) { attempt ->
try {
val result = operation()
// Return success immediately
if (result.isSuccess()) {
return result
}
// Check if the error is retryable
val error = result.getErrorOrNull()
if (error != null && shouldRetry(error, config)) {
lastError = error
// Don't delay on the last attempt
if (attempt < config.maxAttempts - 1) {
delay(currentDelay)
currentDelay = minOf(
(currentDelay * config.backoffMultiplier).toLong(),
config.maxDelayMs
)
}
} else {
// Non-retryable error, return immediately
return result
}
} catch (e: Exception) {
val networkException = e.toNetworkException()
lastError = networkException.apiError
if (shouldRetry(networkException.apiError, config)) {
if (attempt < config.maxAttempts - 1) {
delay(currentDelay)
currentDelay = minOf(
(currentDelay * config.backoffMultiplier).toLong(),
config.maxDelayMs
)
}
} else {
return RepositoryResult.Error(networkException.apiError)
}
}
}
// All attempts exhausted, return last error
return RepositoryResult.Error(
lastError ?: ApiError(
code = "MAX_RETRIES_EXCEEDED",
message = "Maximum retry attempts exceeded"
)
)
}
/**
* Check if an error should trigger a retry
*/
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
return config.retryableExceptions.contains(error.code)
}
/**
* Network connectivity checker (simplified for shared module)
*/
object ConnectivityChecker {
private var isOnline: Boolean = true
private var lastCheckMillis: Long = 0L
fun setOnlineStatus(online: Boolean) {
isOnline = online
lastCheckMillis = currentTimeMillis()
}
fun isOnline(): Boolean = isOnline
fun getLastCheckMillis(): Long = lastCheckMillis
/**
* Simple connectivity test by attempting a lightweight operation
*/
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
return try {
val result = testOperation()
setOnlineStatus(result)
result
} catch (_: Exception) {
setOnlineStatus(false)
false
}
}
}
/**
* Circuit breaker pattern for network operations
*/
class CircuitBreaker(
private val failureThreshold: Int = 5,
private val recoveryTimeoutMs: Long = 60000L,
private val successThreshold: Int = 3
) {
private enum class State { CLOSED, OPEN, HALF_OPEN }
private var state = State.CLOSED
private var failureCount = 0
private var successCount = 0
private var lastFailureTime = 0L
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
when (state) {
State.OPEN -> {
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
state = State.HALF_OPEN
successCount = 0
} else {
return RepositoryResult.Error(
ApiError(
code = "CIRCUIT_BREAKER_OPEN",
message = "Circuit breaker is open, requests blocked"
)
)
}
}
State.HALF_OPEN -> {
// Allow limited requests to test recovery
}
State.CLOSED -> {
// Normal operation
}
}
return try {
val result = operation()
if (result.isSuccess()) {
onSuccess()
} else {
onFailure()
}
result
} catch (e: Exception) {
onFailure()
val networkException = e.toNetworkException()
RepositoryResult.Error(networkException.apiError)
}
}
private fun onSuccess() {
failureCount = 0
when (state) {
State.HALF_OPEN -> {
successCount++
if (successCount >= successThreshold) {
state = State.CLOSED
}
}
else -> {
state = State.CLOSED
}
}
}
private fun onFailure() {
failureCount++
lastFailureTime = currentTimeMillis()
if (failureCount >= failureThreshold) {
state = State.OPEN
}
}
fun getState(): String = state.name
fun getFailureCount(): Int = failureCount
}
}
@@ -1,36 +0,0 @@
package at.mocode.clients.shared.presentation.actions
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
sealed class AppAction {
// Auth Actions
sealed class Auth : AppAction() {
data class LoginStart(val username: String, val password: String) : Auth()
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
data class LoginFailure(val error: String) : Auth()
object Logout : Auth()
data class RefreshToken(val newToken: AuthToken) : Auth()
}
// Navigation Actions
sealed class Navigation : AppAction() {
data class NavigateTo(val route: String) : Navigation()
object NavigateBack : Navigation()
data class UpdateHistory(val route: String) : Navigation()
}
// UI Actions
sealed class UI : AppAction() {
object ToggleDarkMode : UI()
data class SetLoading(val isLoading: Boolean) : UI()
data class ShowNotification(val notification: at.mocode.clients.shared.presentation.state.Notification) : UI()
data class DismissNotification(val id: String) : UI()
}
// Network Actions
sealed class Network : AppAction() {
data class SetOnlineStatus(val isOnline: Boolean) : Network()
data class UpdateLastSync(val timestamp: String) : Network()
}
}
@@ -1,55 +0,0 @@
package at.mocode.clients.shared.presentation.state
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import kotlinx.serialization.Serializable
@Serializable
data class AppState(
val auth: AuthState = AuthState(),
val navigation: NavigationState = NavigationState(),
val ui: UiState = UiState(),
val network: NetworkState = NetworkState()
)
@Serializable
data class AuthState(
val isAuthenticated: Boolean = false,
val user: User? = null,
val token: AuthToken? = null,
val isLoading: Boolean = false,
val error: String? = null
)
@Serializable
data class NavigationState(
val currentRoute: String = "/",
val history: List<String> = emptyList(),
val canGoBack: Boolean = false
)
@Serializable
data class UiState(
val isDarkMode: Boolean = false,
val isLoading: Boolean = false,
val notifications: List<Notification> = emptyList()
)
@Serializable
data class NetworkState(
val isOnline: Boolean = true,
val lastSync: String? = null
)
@Serializable
data class Notification(
val id: String,
val title: String,
val message: String,
val type: NotificationType = NotificationType.INFO,
val timestamp: String
)
enum class NotificationType {
INFO, SUCCESS, WARNING, ERROR
}
@@ -1,137 +0,0 @@
package at.mocode.clients.shared.presentation.store
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class AppStore(
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow()
fun dispatch(action: AppAction) {
scope.launch {
val currentState = _state.value
val newState = reduce(currentState, action)
_state.value = newState
// Handle side effects
handleSideEffect(action, newState)
}
}
private fun reduce(currentState: AppState, action: AppAction): AppState {
return when (action) {
is AppAction.Auth -> currentState.copy(
auth = reduceAuth(currentState.auth, action)
)
is AppAction.Navigation -> currentState.copy(
navigation = reduceNavigation(currentState.navigation, action)
)
is AppAction.UI -> currentState.copy(
ui = reduceUI(currentState.ui, action)
)
is AppAction.Network -> currentState.copy(
network = reduceNetwork(currentState.network, action)
)
}
}
private fun reduceAuth(currentAuth: at.mocode.clients.shared.presentation.state.AuthState, action: AppAction.Auth): at.mocode.clients.shared.presentation.state.AuthState {
return when (action) {
is AppAction.Auth.LoginStart -> currentAuth.copy(
isLoading = true,
error = null
)
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
isAuthenticated = true,
user = action.user,
token = action.token,
isLoading = false,
error = null
)
is AppAction.Auth.LoginFailure -> currentAuth.copy(
isAuthenticated = false,
user = null,
token = null,
isLoading = false,
error = action.error
)
is AppAction.Auth.Logout -> at.mocode.clients.shared.presentation.state.AuthState()
is AppAction.Auth.RefreshToken -> currentAuth.copy(
token = action.newToken
)
}
}
private fun reduceNavigation(currentNav: at.mocode.clients.shared.presentation.state.NavigationState, action: AppAction.Navigation): at.mocode.clients.shared.presentation.state.NavigationState {
return when (action) {
is AppAction.Navigation.NavigateTo -> currentNav.copy(
currentRoute = action.route,
history = currentNav.history + currentNav.currentRoute,
canGoBack = true
)
is AppAction.Navigation.NavigateBack -> {
val newHistory = currentNav.history.dropLast(1)
currentNav.copy(
currentRoute = newHistory.lastOrNull() ?: "/",
history = newHistory,
canGoBack = newHistory.isNotEmpty()
)
}
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
currentRoute = action.route
)
}
}
private fun reduceUI(currentUI: at.mocode.clients.shared.presentation.state.UiState, action: AppAction.UI): at.mocode.clients.shared.presentation.state.UiState {
return when (action) {
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
isDarkMode = !currentUI.isDarkMode
)
is AppAction.UI.SetLoading -> currentUI.copy(
isLoading = action.isLoading
)
is AppAction.UI.ShowNotification -> currentUI.copy(
notifications = currentUI.notifications + action.notification
)
is AppAction.UI.DismissNotification -> currentUI.copy(
notifications = currentUI.notifications.filter { it.id != action.id }
)
}
}
private fun reduceNetwork(currentNetwork: at.mocode.clients.shared.presentation.state.NetworkState, action: AppAction.Network): at.mocode.clients.shared.presentation.state.NetworkState {
return when (action) {
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
isOnline = action.isOnline
)
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
lastSync = action.timestamp
)
}
}
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
when (action) {
is AppAction.Auth.LoginSuccess -> {
// Auto-save token to local storage
// TODO: Implement storage
}
is AppAction.Auth.Logout -> {
// Clear local storage
// TODO: Implement storage cleanup
}
else -> { /* No side effects */ }
}
}
fun cleanup() {
scope.cancel()
}
}
@@ -1,69 +0,0 @@
package at.mocode.clients.shared.presentation.store
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import at.mocode.clients.shared.presentation.actions.AppAction
import kotlinx.coroutines.Dispatchers
import kotlin.test.*
class AppStoreTest {
@Test
fun `store should be created successfully`() {
val store = AppStore(Dispatchers.Unconfined)
assertNotNull(store)
store.cleanup()
}
@Test
fun `auth actions should update state`() {
val store = AppStore(Dispatchers.Unconfined)
// Test login start action
store.dispatch(AppAction.Auth.LoginStart("testuser", "password"))
// Test login success
val user = User("1", "test", "test@example.com", "Test", "User")
val token = AuthToken("access", "refresh", 3600)
store.dispatch(AppAction.Auth.LoginSuccess(user, token))
// Test logout
store.dispatch(AppAction.Auth.Logout)
store.cleanup()
assertTrue(true) // Basic test to verify actions don't throw exceptions
}
@Test
fun `navigation actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.Navigation.NavigateTo("/dashboard"))
store.dispatch(AppAction.Navigation.NavigateBack)
store.cleanup()
assertTrue(true)
}
@Test
fun `ui actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.UI.ToggleDarkMode)
store.dispatch(AppAction.UI.SetLoading(true))
store.cleanup()
assertTrue(true)
}
@Test
fun `network actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.Network.SetOnlineStatus(false))
store.dispatch(AppAction.Network.UpdateLastSync("2024-01-01T12:00:00Z"))
store.cleanup()
assertTrue(true)
}
}
@@ -1,18 +0,0 @@
package at.mocode.clients.shared.test
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
expect fun runBlockingTest(block: suspend () -> Unit)
abstract class BaseTest {
@BeforeTest
fun setupTest() {
// Set up a common test environment
}
@AfterTest
fun teardownTest() {
// Cleanup test environment
}
}
@@ -1,5 +0,0 @@
package at.mocode.clients.shared.network
import kotlin.js.Date
actual fun currentTimeMillis(): Long = Date.now().toLong()
@@ -1,10 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.*
@OptIn(DelicateCoroutinesApi::class)
actual fun runBlockingTest(block: suspend () -> Unit) {
GlobalScope.promise {
block()
}
}
@@ -1,3 +0,0 @@
package at.mocode.clients.shared.network
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
@@ -1,9 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.test.*
actual fun runBlockingTest(block: suspend () -> Unit) {
runTest {
block()
}
}
@@ -1,11 +0,0 @@
package at.mocode.clients.shared.network
// WASM implementation using a simple counter-approach
// Since we don't have direct access to system time in WASM,
// we'll use a monotonic counter for relative timing
private var wasmTimeCounter: Long = 0L
actual fun currentTimeMillis(): Long {
wasmTimeCounter += 1
return wasmTimeCounter
}
@@ -1,11 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.*
@OptIn(DelicateCoroutinesApi::class, ExperimentalWasmJsInterop::class)
actual fun runBlockingTest(block: suspend () -> Unit) {
// WASM-JS uses the same approach as regular JS
GlobalScope.promise {
block()
}
}
+37
View File
@@ -141,6 +141,43 @@ services:
networks:
- 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:
postgres-data:
pgadmin-data:
+52 -8
View File
@@ -29,7 +29,7 @@ services:
- postgres
redis:
image: redis:7-alpine
image: redis:8.4-alpine
container_name: ${COMPOSE_PROJECT_NAME}-redis
restart: unless-stopped
ports:
@@ -105,7 +105,7 @@ services:
- pgadmin
prometheus:
image: prom/prometheus:v2.54.1
image: prom/prometheus:v3.7.3
container_name: ${COMPOSE_PROJECT_NAME}-prometheus
restart: unless-stopped
ports:
@@ -128,7 +128,7 @@ services:
- prometheus
grafana:
image: grafana/grafana:11.3.0
image: grafana/grafana:12.3
container_name: ${COMPOSE_PROJECT_NAME}-grafana
restart: unless-stopped
environment:
@@ -157,7 +157,7 @@ services:
# ==========================================
consul:
image: hashicorp/consul:1.15
image: hashicorp/consul:1.22.1
container_name: ${COMPOSE_PROJECT_NAME}-consul
restart: unless-stopped
ports:
@@ -182,7 +182,7 @@ services:
GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21
VERSION: 1.0.0
BUILD_DATE: "2025-11-25"
BUILD_DATE: "2025-11-29"
container_name: ${COMPOSE_PROJECT_NAME}-gateway
restart: no
ports:
@@ -230,7 +230,7 @@ services:
GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21
VERSION: 1.0.0
BUILD_DATE: "2025-11-21"
BUILD_DATE: "2025-11-29"
container_name: ${COMPOSE_PROJECT_NAME}-ping-service
restart: no # "${RESTART_POLICY:-unless-stopped}"
ports:
@@ -246,7 +246,7 @@ services:
SPRING_CLOUD_CONSUL_PORT: 8500
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service
# --- DATENBANK VERBINDUNG (Das hat gefehlt!) ---
# --- DATENBANK VERBINDUNG ---
# Wir nutzen die Container-Namen aus deiner .env Variable
SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
@@ -254,7 +254,7 @@ services:
# WICHTIG: Wir wollen nur validieren, nichts erstellen.
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
# --- REDIS (DAS HAT GEFEHLT!) ---
# --- REDIS ---
# Wir nutzen den Service-Namen, genau wie bei Postgres
SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis
SPRING_DATA_REDIS_PORT: 6379
@@ -270,6 +270,50 @@ services:
aliases:
- 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:
postgres-data:
pgadmin-data:
-8
View File
@@ -1,8 +0,0 @@
# Optional Client Override Web App
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn die Web-App abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# WEB_APP_PORT=4001
# NODE_ENV=development
# APP_TITLE=Meldestelle (Dev)
-8
View File
@@ -1,8 +0,0 @@
# Optional Infrastructure Override API Gateway
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn das Gateway abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# GATEWAY_PORT=8081
# SPRING_PROFILES_ACTIVE=docker,keycloak
# LOGGING_LEVEL_ROOT=DEBUG
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override Events Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Events-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8085
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override Horses Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Horses-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8084
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override Masterdata Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Masterdata-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8086
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override Members Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Members-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8083
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override Ping Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Ping-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8082
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-1
View File
@@ -1 +0,0 @@
# Core\n\nMinimal placeholder README. See docs/index.md for project documentation
+2
View File
@@ -24,6 +24,8 @@ kotlin {
// Opt-in to experimental Kotlin UUID API across all source sets
all {
languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi")
// Opt-in für kotlin.time.ExperimentalTime projektweit, solange Teile noch experimentell sind
languageSettings.optIn("kotlin.time.ExperimentalTime")
}
commonMain.dependencies {
@@ -1,11 +1,11 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.domain.event
import at.mocode.core.domain.model.*
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinxInstantSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Clock as KtClock
import kotlin.time.Instant
import kotlin.uuid.Uuid
@@ -13,7 +13,6 @@ import kotlin.uuid.Uuid
* Basis-Interface für alle Domain-Events im System.
* Ein Domain-Event beschreibt ein fachlich relevantes Ereignis, das stattgefunden hat.
*/
@OptIn(ExperimentalTime::class)
interface DomainEvent {
val eventId: EventId
val aggregateId: AggregateId
@@ -28,13 +27,13 @@ interface DomainEvent {
* Abstrakte Basisklasse für Domain-Events, um Boilerplate zu reduzieren.
*/
@Serializable
@OptIn(ExperimentalTime::class)
abstract class BaseDomainEvent(
override val aggregateId: AggregateId,
override val eventType: EventType,
override val version: EventVersion,
override val eventId: EventId = EventId(Uuid.random()),
@Serializable(with = KotlinInstantSerializer::class)
@Serializable(with = KotlinxInstantSerializer::class)
override val timestamp: Instant,
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
@@ -58,8 +57,7 @@ abstract class BaseDomainEvent(
)
companion object {
@OptIn(ExperimentalTime::class)
private fun createTimestamp(): Instant = Clock.System.now()
private fun createTimestamp(): Instant = Instant.parse(KtClock.System.now().toString())
}
}
@@ -1,6 +1,6 @@
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinxInstantSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
@@ -15,14 +15,13 @@ interface BaseDto
* Basis-DTO für Domänen-Entitäten mit eindeutiger ID und Audit-Zeitstempeln.
*/
@Serializable
@OptIn(ExperimentalTime::class)
abstract class EntityDto : BaseDto {
abstract val id: EntityId
@Serializable(with = KotlinInstantSerializer::class)
@Serializable(with = KotlinxInstantSerializer::class)
abstract val createdAt: Instant
@Serializable(with = KotlinInstantSerializer::class)
@Serializable(with = KotlinxInstantSerializer::class)
abstract val updatedAt: Instant
}
@@ -40,19 +39,21 @@ data class ErrorDto(
* Standardisierte Hülle für API-Antworten mit einheitlicher Struktur.
*/
@Serializable
@OptIn(ExperimentalTime::class)
data class ApiResponse<T>(
val data: T?,
val success: Boolean,
val errors: List<ErrorDto> = emptyList(),
@Serializable(with = KotlinInstantSerializer::class)
@Serializable(with = KotlinxInstantSerializer::class)
val timestamp: Instant
) {
companion object {
@OptIn(ExperimentalTime::class)
fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(data = data, success = true, timestamp = Clock.System.now())
}
fun <T> success(data: T): ApiResponse<T> =
ApiResponse(
data = data,
success = true,
timestamp = Instant.parse(Clock.System.now().toString())
)
@OptIn(ExperimentalTime::class)
fun <T> error(
@@ -64,11 +65,10 @@ data class ApiResponse<T>(
data = null,
success = false,
errors = listOf(ErrorDto(code = code, message = message, field = field)),
timestamp = Clock.System.now()
timestamp = Instant.parse(Clock.System.now().toString())
)
}
@OptIn(ExperimentalTime::class)
fun <T> error(
code: String,
message: String,
@@ -79,7 +79,12 @@ data class ApiResponse<T>(
@OptIn(ExperimentalTime::class)
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
return ApiResponse(data = null, success = false, errors = errors, timestamp = Clock.System.now())
return ApiResponse(
data = null,
success = false,
errors = errors,
timestamp = Instant.parse(Clock.System.now().toString())
)
}
}
}
@@ -0,0 +1,16 @@
package at.mocode.core.domain.model
/**
* Zentrale Sammlung der standardisierten Fehlercodes der Anwendung.
* Dient als Single-Source-of-Truth, um Inkonsistenzen zu vermeiden.
*/
object ErrorCodes {
val DUPLICATE_ENTRY = ErrorCode("DUPLICATE_ENTRY")
val CONSTRAINT_VIOLATION = ErrorCode("CONSTRAINT_VIOLATION")
val FOREIGN_KEY_VIOLATION = ErrorCode("FOREIGN_KEY_VIOLATION")
val CHECK_VIOLATION = ErrorCode("CHECK_VIOLATION")
val DATABASE_TIMEOUT = ErrorCode("DATABASE_TIMEOUT")
val DATABASE_ERROR = ErrorCode("DATABASE_ERROR")
val TRANSACTION_ERROR = ErrorCode("TRANSACTION_ERROR")
val VALIDATION_ERROR = ErrorCode("VALIDATION_ERROR")
}
@@ -1,4 +1,5 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.UuidSerializer
@@ -1,7 +1,6 @@
@file:OptIn(kotlin.time.ExperimentalTime::class)
package at.mocode.core.domain.serialization
import kotlinx.datetime.Instant
import kotlin.time.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -10,7 +9,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Serializer for kotlinx.datetime.Instant.
* Serializer for kotlin.time.Instant.
* Uses ISO-8601 string representation.
*/
object KotlinxInstantSerializer : KSerializer<Instant> {
@@ -1,4 +1,5 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.utils
import at.mocode.core.domain.model.*
@@ -147,10 +147,14 @@ sealed class Result<out T> {
is Failure -> try {
Success(transform(errors))
} catch (e: Exception) {
Failure(listOf(ErrorDto(
Failure(
listOf(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"),
message = e.message ?: "Recovery failed with an unknown error"
)))
)
)
)
}
}
@@ -241,41 +245,55 @@ sealed class Result<out T> {
inline fun <T> runCatching(operation: () -> T): Result<T> = try {
success(operation())
} catch (e: IllegalArgumentException) {
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"),
message = e.message ?: "Invalid argument provided"
))
)
)
} catch (e: IllegalStateException) {
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"),
message = e.message ?: "Operation called in invalid state"
))
)
)
} catch (e: UnsupportedOperationException) {
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"),
message = e.message ?: "Operation not supported"
))
)
)
} catch (e: IndexOutOfBoundsException) {
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"),
message = e.message ?: "Index out of bounds"
))
)
)
} catch (e: NullPointerException) {
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"),
message = e.message ?: "Unexpected null reference"
))
)
)
} catch (e: ClassCastException) {
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"),
message = e.message ?: "Type mismatch occurred"
))
)
)
} catch (e: Exception) {
// Fallback for any other exception type
failure(ErrorDto(
failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"),
message = e.message ?: "Unknown error occurred"
))
)
)
}
/**
@@ -331,8 +349,10 @@ fun <T> T?.toResult(errorMessage: String = "Value is null"): Result<T> =
if (this != null) {
Result.success(this)
} else {
Result.failure(ErrorDto(
Result.failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"),
message = errorMessage
))
)
)
}
@@ -89,7 +89,7 @@ object ValidationRules {
*/
fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length < min) {
ValidationError.invalidLength(fieldName, "$fieldName must be at least $min characters long")
ValidationError.invalidLength(fieldName, "$fieldName muss mindestens $min Zeichen lang sein")
} else null
}
@@ -98,7 +98,7 @@ object ValidationRules {
*/
fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not exceed $max characters")
ValidationError.invalidLength(fieldName, "$fieldName darf $max Zeichen nicht überschreiten")
} else null
}
@@ -117,7 +117,7 @@ object ValidationRules {
fun email(): ValidationRule<String> = ValidationRule { fieldName, value ->
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!value.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
ValidationError.invalidFormat(fieldName, "$fieldName muss eine gültige E-Mail-Adresse sein")
} else null
}
@@ -128,7 +128,7 @@ object ValidationRules {
*/
fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value < minValue) {
ValidationError.invalidRange(fieldName, "$fieldName must be at least $minValue")
ValidationError.invalidRange(fieldName, "$fieldName muss mindestens $minValue sein")
} else null
}
@@ -137,7 +137,7 @@ object ValidationRules {
*/
fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value > maxValue) {
ValidationError.invalidRange(fieldName, "$fieldName must not exceed $maxValue")
ValidationError.invalidRange(fieldName, "$fieldName darf $maxValue nicht überschreiten")
} else null
}
@@ -146,7 +146,7 @@ object ValidationRules {
*/
fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() <= 0) {
ValidationError.invalidRange(fieldName, "$fieldName must be positive")
ValidationError.invalidRange(fieldName, "$fieldName muss positiv sein")
} else null
}
@@ -155,7 +155,7 @@ object ValidationRules {
*/
fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() < 0) {
ValidationError.invalidRange(fieldName, "$fieldName must not be negative")
ValidationError.invalidRange(fieldName, "$fieldName darf nicht negativ sein")
} else null
}
@@ -175,7 +175,7 @@ object ValidationRules {
*/
fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size < min) {
ValidationError.invalidLength(fieldName, "$fieldName must contain at least $min items")
ValidationError.invalidLength(fieldName, "$fieldName muss mindestens $min Elemente enthalten")
} else null
}
@@ -184,7 +184,7 @@ object ValidationRules {
*/
fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not contain more than $max items")
ValidationError.invalidLength(fieldName, "$fieldName darf nicht mehr als $max Elemente enthalten")
} else null
}
@@ -219,6 +219,6 @@ fun String?.validateEmail(fieldName: String): ValidationError? {
if (this.isNullOrBlank()) return ValidationError.required(fieldName)
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return if (!this.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
ValidationError.invalidFormat(fieldName, "$fieldName muss eine gültige E-Mail-Adresse sein")
} else null
}
@@ -50,7 +50,8 @@ class ResultTest {
assertTrue(combined is Result.Success)
assertEquals(listOf(1, 2), (combined as Result.Success).value)
val combinedFail = Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
val combinedFail =
Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
assertTrue(combinedFail is Result.Failure)
assertEquals(2, (combinedFail as Result.Failure).errors.size)
}
@@ -75,7 +76,8 @@ class ResultTest {
val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" }
assertTrue(rec is Result.Success)
val recFail = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
val recFail =
Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
assertTrue(recFail is Result.Failure)
assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value)
}
@@ -1,12 +1,13 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorCode
import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.PagedResponse
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.BatchInsertStatement
import org.jetbrains.exposed.sql.transactions.transaction
import java.sql.SQLException
import java.sql.SQLTimeoutException
/**
* JVM-specific database utilities for the Core module.
@@ -28,26 +29,36 @@ inline fun <T> transactionResult(
return try {
val result = transaction(database) { block() }
Result.success(result)
} catch (e: SQLTimeoutException) {
Result.failure(
ErrorDto(
code = ErrorCodes.DATABASE_TIMEOUT,
message = "Datenbank-Operation wegen Timeout fehlgeschlagen"
)
)
} catch (e: SQLException) {
// Handle specific SQL exceptions
val errorCode = when {
e.message?.contains("constraint", ignoreCase = true) == true -> "CONSTRAINT_VIOLATION"
e.message?.contains("duplicate", ignoreCase = true) == true -> "DUPLICATE_ENTRY"
e.message?.contains("timeout", ignoreCase = true) == true -> "DATABASE_TIMEOUT"
else -> "DATABASE_ERROR"
// Robustere Fehlerbehandlung über SQLSTATE (Postgres)
val mapped = when (e.sqlState) {
// unique_violation
"23505" -> ErrorCodes.DUPLICATE_ENTRY
// foreign_key_violation
"23503" -> ErrorCodes.FOREIGN_KEY_VIOLATION
// check_violation
"23514" -> ErrorCodes.CHECK_VIOLATION
else -> ErrorCodes.DATABASE_ERROR
}
Result.failure(
ErrorDto(
code = ErrorCode(errorCode),
message = "Database operation failed: ${e.message}"
code = mapped,
message = "Datenbank-Operation fehlgeschlagen"
)
)
} catch (e: Exception) {
Result.failure(
ErrorDto(
code = ErrorCode("TRANSACTION_ERROR"),
message = "Transaction failed: ${e.message ?: "Unknown error"}"
code = ErrorCodes.TRANSACTION_ERROR,
message = "Transaktion fehlgeschlagen"
)
)
}
@@ -145,10 +156,12 @@ object DatabaseUtils {
fun tableExists(tableName: String, database: Database? = null): Boolean {
return try {
transaction(database) {
// Execute a safer SQL statement to check if table exists
val result = exec("SELECT 1 FROM information_schema.tables WHERE table_name = '$tableName' LIMIT 1")
// If the query returns a result, the table exists
result != null
// Postgres-spezifischer, robuster Ansatz über to_regclass
val valid = tableName.trim()
if (!valid.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) return@transaction false
exec("SELECT to_regclass('$valid')") { rs ->
if (rs.next()) rs.getString(1) else null
} != null
}
} catch (e: Exception) {
false
@@ -158,28 +171,55 @@ object DatabaseUtils {
/**
* Creates an index if it doesn't exist.
*/
@JvmName("createIndexIfNotExistsArray")
fun createIndexIfNotExists(
tableName: String,
indexName: String,
columns: Array<String>,
unique: Boolean = false,
database: Database? = null
): Result<Unit> = createIndexIfNotExists(tableName, indexName, *columns, unique = unique, database = database)
@JvmName("createIndexIfNotExistsVararg")
fun createIndexIfNotExists(
tableName: String,
indexName: String,
vararg columns: String,
unique: Boolean = false,
database: Database? = null
): Result<Unit> {
return transactionResult(database) {
// Einfache Sanitization + Quoting der Identifier
fun quoteIdent(name: String): String {
require(name.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) { "Ungültiger Identifier: $name" }
return "\"$name\""
}
val uniqueStr = if (unique) "UNIQUE" else ""
val columnsStr = columns.joinToString(", ")
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $indexName ON $tableName ($columnsStr)"
val qTable = quoteIdent(tableName)
val qIndex = quoteIdent(indexName)
val cols = columns.map { quoteIdent(it) }.joinToString(", ")
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $qIndex ON $qTable ($cols)"
exec(sql)
Unit
}
}
/**
* Executes a raw SQL query and returns the number of affected rows.
* Führt ein beliebiges SQL-Statement aus (DDL/DML). Liefert keinen Update-Count zurück.
*/
fun executeRawSql(sql: String, database: Database? = null): Result<Int> {
return transactionResult(database) {
(exec(sql) ?: 0) as Int
fun executeRawSql(sql: String, database: Database? = null): Result<Unit> = transactionResult(database) {
exec(sql)
Unit
}
/**
* Executes a raw SQL update statement and returns affected rows.
*/
fun executeUpdate(sql: String, database: Database? = null): Result<Int> = transactionResult(database) {
// Nutzt Exposed PreparedStatementApi, kein AutoCloseable
val ps = this.connection.prepareStatement(sql, false)
ps.executeUpdate()
}
/**
@@ -182,10 +182,12 @@
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://localhost:4000/*",
"http://localhost:3000/*",
"https://app.meldestelle.at/*"
],
"webOrigins": [
"http://localhost:4000",
"http://localhost:3000",
"https://app.meldestelle.at"
],
@@ -17,6 +17,7 @@ COPY gradlew ./
# Kopiere alle notwendigen Module für Multi-Modul-Projekt
COPY clients ./clients
COPY core ./core
COPY domains ./domains
COPY platform ./platform
COPY infrastructure ./infrastructure
COPY services ./services
+20 -6
View File
@@ -9,6 +9,9 @@
ARG GRADLE_VERSION
ARG JAVA_VERSION
ARG NODE_VERSION
ARG NGINX_IMAGE_TAG=1.28.0-alpine
# Toggle build profile: dev (default) or prod
ARG WEB_BUILD_PROFILE=dev
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION} AS builder
# Install Node.js (version aligned with versions.toml)
@@ -29,6 +32,7 @@ COPY gradlew ./
# Kopiere alle notwendigen Module für Multi-Modul-Projekt
COPY clients ./clients
COPY core ./core
COPY domains ./domains
COPY platform ./platform
COPY infrastructure ./infrastructure
COPY services ./services
@@ -40,21 +44,28 @@ RUN chmod +x ./gradlew
# Dependencies downloaden (für besseres Caching)
RUN ./gradlew :clients:app:dependencies --no-configure-on-demand
# Kotlin/JS Web-App kompilieren (PRODUCTION Build)
RUN ./gradlew :clients:app:jsBrowserDistribution --no-configure-on-demand -Pproduction=true
# Kotlin/JS Web-App kompilieren (Profil wählbar über WEB_BUILD_PROFILE)
# - dev → jsBrowserDevelopmentExecutable (schneller, Source Maps)
# - prod → jsBrowserDistribution (minifiziert, optimiert)
RUN if [ "$WEB_BUILD_PROFILE" = "prod" ]; then \
./gradlew :clients:app:jsBrowserDistribution --no-configure-on-demand -Pproduction=true; \
mkdir -p /app/web-dist && cp -r clients/app/build/dist/js/productionExecutable/* /app/web-dist/; \
else \
./gradlew :clients:app:jsBrowserDevelopmentExecutable --no-configure-on-demand; \
mkdir -p /app/web-dist && cp -r clients/app/build/dist/js/developmentExecutable/* /app/web-dist/; \
fi
# ===================================================================
# Stage 2: Runtime Stage - Nginx für Static Files + API Proxy
# ===================================================================
# Build arg controls runtime base image tag (build-time only)
ARG NGINX_IMAGE_TAG
# Build arg controls runtime base image tag (declared globally to allow usage in FROM)
FROM nginx:${NGINX_IMAGE_TAG}
# Installiere curl für Health-Checks
RUN apk add --no-cache curl
# Kopiere kompilierte Web-App von Build-Stage
COPY --from=builder /app/clients/app/build/dist/js/productionExecutable/ /usr/share/nginx/html/
# Kopiere kompilierte Web-App von Build-Stage (vereinheitlichtes Ausgabeverzeichnis)
COPY --from=builder /app/web-dist/ /usr/share/nginx/html/
# Kopiere Nginx-Konfiguration
COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
@@ -62,6 +73,9 @@ COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
# Exponiere Port 4000 (statt Standard 80)
EXPOSE 4000
# Downloads (Platzhalter) ausliefern lassen
COPY dockerfiles/clients/web-app/downloads/ /usr/share/nginx/html/downloads/
# Health-Check für Container
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:4000/ || exit 1
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meldestelle Desktop Downloads (Platzhalter)</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 2rem; }
h1 { margin-bottom: .25rem; }
.muted { color: #666; }
ul { line-height: 1.8; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem 1.25rem; max-width: 720px; }
</style>
</head>
<body>
<h1>Desktop Downloads</h1>
<p class="muted">Platzhalter-Verzeichnis. Hier können zukünftig Installer/Archive der Desktop-App bereitgestellt werden.</p>
<div class="card">
<p>Lege deine Dateien in dieses Verzeichnis im Repository:</p>
<pre><code>dockerfiles/clients/web-app/downloads/</code></pre>
<p>Oder mounte in Docker Compose ein Host-Verzeichnis auf <code>/usr/share/nginx/html/downloads</code>.</p>
<p>Beispiele (geplant):</p>
<ul>
<li>Meldestelle-Setup-1.0.0.msi (Windows)</li>
<li>Meldestelle-1.0.0.dmg (macOS)</li>
<li>Meldestelle-1.0.0.deb (Linux)</li>
</ul>
</div>
</body>
</html>

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