diff --git a/clients/app/build.gradle.kts b/clients/app/build.gradle.kts new file mode 100644 index 00000000..6076f072 --- /dev/null +++ b/clients/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +group = "at.mocode.clients" +version = "1.0.0" + +kotlin { + jvm() + js { + browser() + } + + jvmToolchain(21) + + sourceSets { + val commonMain by getting { + dependencies { + // Feature modules + implementation(project(":clients:ping-feature")) + + // Shared modules + implementation(project(":clients:shared:common-ui")) + implementation(project(":clients:shared:navigation")) + + // Compose dependencies + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + + // ViewModel lifecycle + implementation(libs.androidx.lifecycle.viewmodelCompose) + } + } + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} diff --git a/clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt b/clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt new file mode 100644 index 00000000..c17e7a9c --- /dev/null +++ b/clients/app/src/commonMain/kotlin/at/mocode/clients/app/App.kt @@ -0,0 +1,41 @@ +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 androidx.lifecycle.viewmodel.compose.viewModel +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 + +@Composable +fun App() { + var currentScreen: AppScreen by remember { mutableStateOf(AppScreen.Home) } + + AppTheme { + AppScaffold( + header = { + AppHeader( + title = "Meldestelle", + onNavigateToPing = { currentScreen = AppScreen.Ping } + ) + } + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + when (currentScreen) { + is AppScreen.Home -> { + LandingScreen() + } + is AppScreen.Ping -> { + val pingViewModel: PingViewModel = viewModel() + PingScreen(viewModel = pingViewModel) + } + } + } + } + } +} diff --git a/clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt b/clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt new file mode 100644 index 00000000..1ce7e69d --- /dev/null +++ b/clients/app/src/commonMain/kotlin/at/mocode/clients/app/LandingScreen.kt @@ -0,0 +1,99 @@ +package at.mocode.clients.app + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun LandingScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + 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 + ) + } +} + +@Composable +private fun TechItem(text: String) { + Text( + text = "• $text", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 2.dp) + ) +} diff --git a/clients/app/src/jsMain/kotlin/main.kt b/clients/app/src/jsMain/kotlin/main.kt new file mode 100644 index 00000000..bb762bb0 --- /dev/null +++ b/clients/app/src/jsMain/kotlin/main.kt @@ -0,0 +1,11 @@ +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import at.mocode.clients.app.App +import kotlinx.browser.document + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + ComposeViewport(document.body!!) { + App() + } +} diff --git a/clients/app/src/jvmMain/kotlin/main.kt b/clients/app/src/jvmMain/kotlin/main.kt new file mode 100644 index 00000000..c05afa54 --- /dev/null +++ b/clients/app/src/jvmMain/kotlin/main.kt @@ -0,0 +1,12 @@ +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import at.mocode.clients.app.App + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Meldestelle - Desktop Application" + ) { + App() + } +} diff --git a/clients/app/src/wasmJsMain/kotlin/main.kt b/clients/app/src/wasmJsMain/kotlin/main.kt new file mode 100644 index 00000000..89966160 --- /dev/null +++ b/clients/app/src/wasmJsMain/kotlin/main.kt @@ -0,0 +1,10 @@ +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import at.mocode.clients.app.App + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + CanvasBasedWindow("Meldestelle - WASM Application") { + App() + } +} diff --git a/clients/ping-client/src/commonMain/kotlin/at/mocode/Constants.kt b/clients/ping-client/src/commonMain/kotlin/at/mocode/Constants.kt deleted file mode 100644 index 372d2e7e..00000000 --- a/clients/ping-client/src/commonMain/kotlin/at/mocode/Constants.kt +++ /dev/null @@ -1,3 +0,0 @@ -package at.mocode - -const val SERVER_PORT = 8081 diff --git a/clients/ping-client/src/commonMain/kotlin/at/mocode/Greeting.kt b/clients/ping-client/src/commonMain/kotlin/at/mocode/Greeting.kt deleted file mode 100644 index af34bc49..00000000 --- a/clients/ping-client/src/commonMain/kotlin/at/mocode/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package at.mocode - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file diff --git a/clients/ping-client/src/commonMain/kotlin/at/mocode/Platform.kt b/clients/ping-client/src/commonMain/kotlin/at/mocode/Platform.kt deleted file mode 100644 index deb5bcd7..00000000 --- a/clients/ping-client/src/commonMain/kotlin/at/mocode/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package at.mocode - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/clients/ping-client/src/commonMain/kotlin/at/mocode/model/PingResponse.kt b/clients/ping-client/src/commonMain/kotlin/at/mocode/model/PingResponse.kt deleted file mode 100644 index 23baf8b3..00000000 --- a/clients/ping-client/src/commonMain/kotlin/at/mocode/model/PingResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package at.mocode.model - -// Deprecated local DTOs are replaced by typealiases to the shared API contract. -// This preserves binary/source compatibility for existing imports while enforcing SSoT. - -typealias PingResponse = at.mocode.ping.api.PingResponse - -typealias EnhancedPingResponse = at.mocode.ping.api.EnhancedPingResponse - -typealias HealthResponse = at.mocode.ping.api.HealthResponse diff --git a/clients/ping-client/src/commonMain/kotlin/at/mocode/ping/client/PingApiClient.kt b/clients/ping-client/src/commonMain/kotlin/at/mocode/ping/client/PingApiClient.kt deleted file mode 100644 index d00c4a08..00000000 --- a/clients/ping-client/src/commonMain/kotlin/at/mocode/ping/client/PingApiClient.kt +++ /dev/null @@ -1,25 +0,0 @@ -package at.mocode.ping.client - -import at.mocode.ping.api.EnhancedPingResponse -import at.mocode.ping.api.HealthResponse -import at.mocode.ping.api.PingApi -import at.mocode.ping.api.PingResponse -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import at.mocode.service.getBaseUrl - -class PingApiClient( - private val client: HttpClient, - baseUrl: String = getBaseUrl() -) : PingApi { - private val base = "$baseUrl/api/ping" - - override suspend fun simplePing(): PingResponse = client.get("$base/simple").body() - - override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse = - client.get("$base/enhanced") { parameter("simulate", simulate) }.body() - - override suspend fun healthCheck(): HealthResponse = client.get("$base/health").body() -} diff --git a/clients/ping-client/src/commonMain/kotlin/at/mocode/service/PingService.kt b/clients/ping-client/src/commonMain/kotlin/at/mocode/service/PingService.kt deleted file mode 100644 index a7933e91..00000000 --- a/clients/ping-client/src/commonMain/kotlin/at/mocode/service/PingService.kt +++ /dev/null @@ -1,43 +0,0 @@ -package at.mocode.service - -import at.mocode.model.EnhancedPingResponse -import at.mocode.model.HealthResponse -import at.mocode.model.PingResponse -import at.mocode.ping.client.PingApiClient -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.json.Json - -@Deprecated("Use PingApiClient directly for new code") -class PingService( - private val client: HttpClient = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - isLenient = true - }) - } - install(HttpTimeout) { - requestTimeoutMillis = 10000 - connectTimeoutMillis = 5000 - } - } -) { - private val api = PingApiClient(client) - - suspend fun ping(): Result = runCatching { api.simplePing() } - - suspend fun enhancedPing(simulate: Boolean = false): Result = - runCatching { api.enhancedPing(simulate) } - - suspend fun health(): Result = runCatching { api.healthCheck() } - - suspend fun testFailure(): Result = runCatching { - throw RuntimeException("Simulated failure for testing") - } -} - -// Platform-specific base URL required by PingApiClient via getBaseUrl() -expect fun getBaseUrl(): String diff --git a/clients/ping-client/src/commonTest/kotlin/at/mocode/SharedCommonTest.kt b/clients/ping-client/src/commonTest/kotlin/at/mocode/SharedCommonTest.kt deleted file mode 100644 index 2057c67b..00000000 --- a/clients/ping-client/src/commonTest/kotlin/at/mocode/SharedCommonTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package at.mocode - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SharedCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/clients/ping-client/src/jsMain/kotlin/at/mocode/Platform.js.kt b/clients/ping-client/src/jsMain/kotlin/at/mocode/Platform.js.kt deleted file mode 100644 index 08ac44a9..00000000 --- a/clients/ping-client/src/jsMain/kotlin/at/mocode/Platform.js.kt +++ /dev/null @@ -1,7 +0,0 @@ -package at.mocode - -class JsPlatform: Platform { - override val name: String = "Web with Kotlin/JS" -} - -actual fun getPlatform(): Platform = JsPlatform() \ No newline at end of file diff --git a/clients/ping-client/src/jsMain/kotlin/at/mocode/service/Platform.js.kt b/clients/ping-client/src/jsMain/kotlin/at/mocode/service/Platform.js.kt deleted file mode 100644 index fb7fef67..00000000 --- a/clients/ping-client/src/jsMain/kotlin/at/mocode/service/Platform.js.kt +++ /dev/null @@ -1,4 +0,0 @@ -package at.mocode.service - -// Use direct ping-service for JS Development - based on central.toml -actual fun getBaseUrl(): String = "http://localhost:8082" diff --git a/clients/ping-client/src/jvmMain/kotlin/at/mocode/Platform.jvm.kt b/clients/ping-client/src/jvmMain/kotlin/at/mocode/Platform.jvm.kt deleted file mode 100644 index 01f5ea8b..00000000 --- a/clients/ping-client/src/jvmMain/kotlin/at/mocode/Platform.jvm.kt +++ /dev/null @@ -1,7 +0,0 @@ -package at.mocode - -class JVMPlatform: Platform { - override val name: String = "Java ${System.getProperty("java.version")}" -} - -actual fun getPlatform(): Platform = JVMPlatform() \ No newline at end of file diff --git a/clients/ping-client/src/jvmMain/kotlin/at/mocode/service/Platform.jvm.kt b/clients/ping-client/src/jvmMain/kotlin/at/mocode/service/Platform.jvm.kt deleted file mode 100644 index bc1633f5..00000000 --- a/clients/ping-client/src/jvmMain/kotlin/at/mocode/service/Platform.jvm.kt +++ /dev/null @@ -1,4 +0,0 @@ -package at.mocode.service - -// Use direct ping-service for JVM (Desktop) - based on central.toml -actual fun getBaseUrl(): String = "http://localhost:8082" diff --git a/clients/ping-client/src/wasmJsMain/kotlin/at/mocode/Platform.wasmJs.kt b/clients/ping-client/src/wasmJsMain/kotlin/at/mocode/Platform.wasmJs.kt deleted file mode 100644 index 0cccfd78..00000000 --- a/clients/ping-client/src/wasmJsMain/kotlin/at/mocode/Platform.wasmJs.kt +++ /dev/null @@ -1,7 +0,0 @@ -package at.mocode - -class WasmPlatform: Platform { - override val name: String = "Web with Kotlin/Wasm" -} - -actual fun getPlatform(): Platform = WasmPlatform() \ No newline at end of file diff --git a/clients/ping-client/src/wasmJsMain/kotlin/at/mocode/service/Platform.wasmJs.kt b/clients/ping-client/src/wasmJsMain/kotlin/at/mocode/service/Platform.wasmJs.kt deleted file mode 100644 index cfea7ed1..00000000 --- a/clients/ping-client/src/wasmJsMain/kotlin/at/mocode/service/Platform.wasmJs.kt +++ /dev/null @@ -1,4 +0,0 @@ -package at.mocode.service - -// Use direct ping-service for WASM Development - based on central.toml -actual fun getBaseUrl(): String = "http://localhost:8082" diff --git a/clients/ping-client/build.gradle.kts b/clients/ping-feature/build.gradle.kts similarity index 58% rename from clients/ping-client/build.gradle.kts rename to clients/ping-feature/build.gradle.kts index 3dac1127..13b12417 100644 --- a/clients/ping-client/build.gradle.kts +++ b/clients/ping-feature/build.gradle.kts @@ -1,9 +1,11 @@ plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlinSerialization) } -group = "at.mocode" +group = "at.mocode.clients" version = "1.0.0" kotlin { @@ -11,25 +13,35 @@ kotlin { js { browser() } - // Keep WASM for dev since sources already present - @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) - wasmJs { - browser() - } jvmToolchain(21) sourceSets { val commonMain by getting { dependencies { + // Contract from backend implementation(projects.services.ping.pingApi) + // UI Kit + implementation(project(":clients:shared:common-ui")) + + // Compose dependencies + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + + // Ktor client for HTTP calls implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentNegotiation) implementation(libs.ktor.client.serialization.kotlinx.json) + // Coroutines and serialization implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + + // ViewModel lifecycle + implementation(libs.androidx.lifecycle.viewmodelCompose) } } val commonTest by getting { diff --git a/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt b/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt new file mode 100644 index 00000000..cce42681 --- /dev/null +++ b/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt @@ -0,0 +1,41 @@ +package at.mocode.clients.pingfeature + +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 io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + +class PingApiClient( + private val baseUrl: String = "http://localhost:8080" +) : PingApi { + + private val client = HttpClient { + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + }) + } + } + + override suspend fun simplePing(): PingResponse { + return client.get("$baseUrl/api/ping/simple").body() + } + + override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { + return client.get("$baseUrl/api/ping/enhanced") { + parameter("simulate", simulate) + }.body() + } + + override suspend fun healthCheck(): HealthResponse { + return client.get("$baseUrl/api/ping/health").body() + } +} diff --git a/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingScreen.kt b/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingScreen.kt new file mode 100644 index 00000000..294f537e --- /dev/null +++ b/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingScreen.kt @@ -0,0 +1,187 @@ +package at.mocode.clients.pingfeature + +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 + +@Composable +fun PingScreen(viewModel: PingViewModel) { + val uiState = viewModel.uiState + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Ping Service", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { viewModel.performSimplePing() }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Simple Ping") + } + + Button( + onClick = { viewModel.performEnhancedPing() }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Enhanced Ping") + } + + Button( + onClick = { viewModel.performHealthCheck() }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Health Check") + } + } + + // Loading indicator + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + // Error message + uiState.errorMessage?.let { error -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { viewModel.clearError() } + ) { + Text("Dismiss") + } + } + } + } + + // Simple Ping Response + uiState.simplePingResponse?.let { response -> + ResponseCard( + title = "Simple Ping Response", + status = response.status, + timestamp = response.timestamp, + service = response.service + ) + } + + // Enhanced Ping Response + uiState.enhancedPingResponse?.let { response -> + ResponseCard( + title = "Enhanced Ping Response", + status = response.status, + timestamp = response.timestamp, + service = response.service, + additionalInfo = mapOf( + "Circuit Breaker State" to response.circuitBreakerState, + "Response Time" to "${response.responseTime}ms" + ) + ) + } + + // Health Response + uiState.healthResponse?.let { response -> + ResponseCard( + title = "Health Check Response", + status = response.status, + timestamp = response.timestamp, + service = response.service, + additionalInfo = mapOf( + "Healthy" to response.healthy.toString() + ) + ) + } + } +} + +@Composable +private fun ResponseCard( + title: String, + status: String, + timestamp: String, + service: String, + additionalInfo: Map = emptyMap() +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + InfoRow("Status", status) + InfoRow("Timestamp", timestamp) + InfoRow("Service", service) + + additionalInfo.forEach { (key, value) -> + InfoRow(key, value) + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "$label:", + fontWeight = FontWeight.Medium + ) + Text(text = value) + } +} diff --git a/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingViewModel.kt b/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingViewModel.kt new file mode 100644 index 00000000..8d97bc30 --- /dev/null +++ b/clients/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingViewModel.kt @@ -0,0 +1,82 @@ +package at.mocode.clients.pingfeature + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.mocode.ping.api.PingResponse +import at.mocode.ping.api.EnhancedPingResponse +import at.mocode.ping.api.HealthResponse +import kotlinx.coroutines.launch + +data class PingUiState( + val isLoading: Boolean = false, + val simplePingResponse: PingResponse? = null, + val enhancedPingResponse: EnhancedPingResponse? = null, + val healthResponse: HealthResponse? = null, + val errorMessage: String? = null +) + +class PingViewModel : ViewModel() { + private val apiClient = PingApiClient() + + var uiState by mutableStateOf(PingUiState()) + private set + + fun performSimplePing() { + viewModelScope.launch { + uiState = uiState.copy(isLoading = true, errorMessage = null) + try { + val response = apiClient.simplePing() + uiState = uiState.copy( + isLoading = false, + simplePingResponse = response + ) + } catch (e: Exception) { + uiState = uiState.copy( + isLoading = false, + errorMessage = "Simple ping failed: ${e.message}" + ) + } + } + } + + fun performEnhancedPing(simulate: Boolean = false) { + viewModelScope.launch { + uiState = uiState.copy(isLoading = true, errorMessage = null) + try { + val response = apiClient.enhancedPing(simulate) + uiState = uiState.copy( + isLoading = false, + enhancedPingResponse = response + ) + } catch (e: Exception) { + uiState = uiState.copy( + isLoading = false, + errorMessage = "Enhanced ping failed: ${e.message}" + ) + } + } + } + + fun performHealthCheck() { + viewModelScope.launch { + uiState = uiState.copy(isLoading = true, errorMessage = null) + try { + val response = apiClient.healthCheck() + uiState = uiState.copy( + isLoading = false, + healthResponse = response + ) + } catch (e: Exception) { + uiState = uiState.copy( + isLoading = false, + errorMessage = "Health check failed: ${e.message}" + ) + } + } + } + + fun clearError() { + uiState = uiState.copy(errorMessage = null) + } +} diff --git a/clients/shared/common-ui/build.gradle.kts b/clients/shared/common-ui/build.gradle.kts new file mode 100644 index 00000000..08dd3c47 --- /dev/null +++ b/clients/shared/common-ui/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +group = "at.mocode.clients.shared" +version = "1.0.0" + +kotlin { + jvm() + js { + browser() + } + + jvmToolchain(21) + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + } + } + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppFooter.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppFooter.kt new file mode 100644 index 00000000..62d1275e --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppFooter.kt @@ -0,0 +1,29 @@ +package at.mocode.clients.shared.commonui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun AppFooter() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "© 2024 Meldestelle - Built with Kotlin Multiplatform", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppHeader.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppHeader.kt new file mode 100644 index 00000000..78000a74 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppHeader.kt @@ -0,0 +1,36 @@ +package at.mocode.clients.shared.commonui.components + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppHeader( + title: String, + onNavigateToPing: (() -> Unit)? = null +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + actions = { + onNavigateToPing?.let { navigateAction -> + TextButton( + onClick = navigateAction + ) { + Text("Ping Service") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppScaffold.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppScaffold.kt new file mode 100644 index 00000000..06d56f77 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/components/AppScaffold.kt @@ -0,0 +1,24 @@ +package at.mocode.clients.shared.commonui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + header: @Composable () -> Unit = { + AppHeader(title = "Meldestelle") + }, + footer: @Composable () -> Unit = { + AppFooter() + }, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + topBar = header, + bottomBar = footer, + content = content + ) +} diff --git a/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/theme/AppTheme.kt b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/theme/AppTheme.kt new file mode 100644 index 00000000..08fecf66 --- /dev/null +++ b/clients/shared/common-ui/src/commonMain/kotlin/at/mocode/clients/shared/commonui/theme/AppTheme.kt @@ -0,0 +1,49 @@ +package at.mocode.clients.shared.commonui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// Define custom colors for the app +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF1976D2), + onPrimary = Color.White, + primaryContainer = Color(0xFFBBDEFB), + onPrimaryContainer = Color(0xFF0D47A1), + secondary = Color(0xFF03DAC6), + onSecondary = Color.Black, + tertiary = Color(0xFF03A9F4), + background = Color(0xFFFAFAFA), + surface = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F) +) + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF90CAF9), + onPrimary = Color(0xFF0D47A1), + primaryContainer = Color(0xFF1565C0), + onPrimaryContainer = Color(0xFFBBDEFB), + secondary = Color(0xFF03DAC6), + onSecondary = Color.Black, + tertiary = Color(0xFF03A9F4), + background = Color(0xFF121212), + surface = Color(0xFF1E1E1E), + onBackground = Color(0xFFE0E0E0), + onSurface = Color(0xFFE0E0E0) +) + +@Composable +fun AppTheme( + darkTheme: Boolean = false, // For now, we'll default to light theme + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/clients/shared/navigation/build.gradle.kts b/clients/shared/navigation/build.gradle.kts new file mode 100644 index 00000000..d6a7e91c --- /dev/null +++ b/clients/shared/navigation/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) +} + +group = "at.mocode.clients.shared" +version = "1.0.0" + +kotlin { + jvm() + js { + browser() + } + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + jvmToolchain(21) + + sourceSets { + val commonMain by getting { + dependencies { + // No specific dependencies needed for navigation routes + } + } + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} diff --git a/clients/shared/navigation/src/commonMain/kotlin/at/mocode/clients/shared/navigation/AppScreen.kt b/clients/shared/navigation/src/commonMain/kotlin/at/mocode/clients/shared/navigation/AppScreen.kt new file mode 100644 index 00000000..a57a2a4c --- /dev/null +++ b/clients/shared/navigation/src/commonMain/kotlin/at/mocode/clients/shared/navigation/AppScreen.kt @@ -0,0 +1,6 @@ +package at.mocode.clients.shared.navigation + +sealed class AppScreen { + data object Home : AppScreen() + data object Ping : AppScreen() +} diff --git a/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheEdgeCasesTest.kt b/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheEdgeCasesTest.kt index 70f7e8b6..2ad5198c 100644 --- a/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheEdgeCasesTest.kt +++ b/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheEdgeCasesTest.kt @@ -80,11 +80,14 @@ class RedisDistributedCacheEdgeCasesTest { try { cache.set("circular-reference", circularObject as Any) logger.info { "Circular reference object was handled (possibly with Jackson's circular reference handling)" } - } catch (e: Exception) { - logger.info { "Circular reference object caused expected serialization issue: ${e::class.simpleName}" } - assertTrue(e is com.fasterxml.jackson.databind.JsonMappingException || - e is StackOverflowError || - e is RuntimeException, "Expected serialization-related exception") + } catch (t: Throwable) { + logger.info { "Circular reference object caused expected serialization issue: ${t::class.simpleName}" } + assertTrue( + t is com.fasterxml.jackson.databind.JsonMappingException || + t is StackOverflowError || + t is RuntimeException, + "Expected serialization-related exception" + ) } // Test 2: Very deep nesting that might cause issues @@ -93,8 +96,8 @@ class RedisDistributedCacheEdgeCasesTest { cache.set("deep-nested", deepObject as Any) cache.get("deep-nested", DeeplyNestedObject::class.java) logger.info { "Deep nested object serialized successfully" } - } catch (e: Exception) { - logger.info { "Deep nested object caused expected issues: ${e::class.simpleName}" } + } catch (t: Throwable) { + logger.info { "Deep nested object caused expected issues: ${t::class.simpleName}" } } // Verify that the cache remains stable after problematic serialization attempts diff --git a/settings.gradle.kts b/settings.gradle.kts index 8faa9f9e..5427c5a3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,7 +59,10 @@ include(":services:ping:ping-api") include(":services:ping:ping-service") // Client modules -include(":clients:ping-client") +include(":clients:app") +include(":clients:ping-feature") +include(":clients:shared:common-ui") +include(":clients:shared:navigation") // Documentation module include(":docs")