From a6a35a2edab4ad965b29a7e13ddf9a92ca92edf2 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Mon, 24 Nov 2025 22:06:28 +0100 Subject: [PATCH] =?UTF-8?q?refactoring:=20Ein=20neues=20Kotlin-Multiplattf?= =?UTF-8?q?orm-Mitgliedermodul=20wurde=20bereitgestellt,=20das=20clientsei?= =?UTF-8?q?tige=20API-Aufrufe,=20Benutzeroberfl=C3=A4che=20und=20Navigatio?= =?UTF-8?q?n=20in=20die=20Host-Anwendung=20integriert.=20Der=20Build=20wur?= =?UTF-8?q?de=20durch=20die=20Entkopplung=20von=20Backend-Mitgliedermodule?= =?UTF-8?q?n=20stabilisiert,=20und=20alle=20Tests=20wurden=20erfolgreich?= =?UTF-8?q?=20abgeschlossen.=20Die=20Client-Funktionen=20erfolgen=20=C3=BC?= =?UTF-8?q?ber=20REST-Aufrufe=20an=20das=20Gateway;=20die=20Backend-Integr?= =?UTF-8?q?ation=20wird=20in=20einer=20sp=C3=A4teren=20Phase=20implementie?= =?UTF-8?q?rt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clients/app/build.gradle.kts | 1 + clients/app/src/commonMain/kotlin/MainApp.kt | 17 +++- clients/members-feature/build.gradle.kts | 87 +++++++++++++++++++ .../membersfeature/MembersApiClient.kt | 21 +++++ .../clients/membersfeature/ProfileScreen.kt | 63 ++++++++++++++ .../membersfeature/ProfileViewModel.kt | 42 +++++++++ .../membersfeature/model/MemberProfile.kt | 16 ++++ .../clients/shared/navigation/AppScreen.kt | 1 + .../KotlinLocalDateSerializer.kt | 32 +++++++ services/members/members-api/build.gradle.kts | 8 +- .../members-application/build.gradle.kts | 2 +- settings.gradle.kts | 37 ++------ 12 files changed, 291 insertions(+), 36 deletions(-) create mode 100644 clients/members-feature/build.gradle.kts create mode 100644 clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/MembersApiClient.kt create mode 100644 clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileScreen.kt create mode 100644 clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileViewModel.kt create mode 100644 clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/model/MemberProfile.kt create mode 100644 core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt diff --git a/clients/app/build.gradle.kts b/clients/app/build.gradle.kts index 5aaf74bc..4bd64b37 100644 --- a/clients/app/build.gradle.kts +++ b/clients/app/build.gradle.kts @@ -77,6 +77,7 @@ kotlin { implementation(project(":clients:shared:common-ui")) implementation(project(":clients:shared:navigation")) implementation(project(":clients:ping-feature")) + implementation(project(":clients:members-feature")) // Compose Multiplatform implementation(compose.runtime) diff --git a/clients/app/src/commonMain/kotlin/MainApp.kt b/clients/app/src/commonMain/kotlin/MainApp.kt index cefe1dfe..552b6a88 100644 --- a/clients/app/src/commonMain/kotlin/MainApp.kt +++ b/clients/app/src/commonMain/kotlin/MainApp.kt @@ -3,6 +3,9 @@ import androidx.compose.runtime.* import androidx.compose.foundation.layout.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import at.mocode.clients.membersfeature.ProfileScreen +import at.mocode.clients.membersfeature.ProfileViewModel +import at.mocode.clients.shared.navigation.AppScreen @Composable fun MainApp() { @@ -11,13 +14,20 @@ fun MainApp() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - DevelopmentScreen() + var currentScreen by remember { mutableStateOf(AppScreen.Home) } + + 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() }) + } } } } @Composable -fun DevelopmentScreen() { +fun DevelopmentScreen(onOpenProfile: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -47,6 +57,9 @@ fun DevelopmentScreen() { Button(onClick = { testStatus = "Testing Ping Service..." }) { Text("Test Ping Service") } + Button(onClick = onOpenProfile) { + Text("Open Profile") + } } Text("Status: $testStatus") diff --git a/clients/members-feature/build.gradle.kts b/clients/members-feature/build.gradle.kts new file mode 100644 index 00000000..a712a6ca --- /dev/null +++ b/clients/members-feature/build.gradle.kts @@ -0,0 +1,87 @@ +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 { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn") + } +} diff --git a/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/MembersApiClient.kt b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/MembersApiClient.kt new file mode 100644 index 00000000..3e1dafc4 --- /dev/null +++ b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/MembersApiClient.kt @@ -0,0 +1,21 @@ +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 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() + } +} diff --git a/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileScreen.kt b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileScreen.kt new file mode 100644 index 00000000..5836fd50 --- /dev/null +++ b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileScreen.kt @@ -0,0 +1,63 @@ +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") + } + } + } +} diff --git a/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileViewModel.kt b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileViewModel.kt new file mode 100644 index 00000000..24f03809 --- /dev/null +++ b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/ProfileViewModel.kt @@ -0,0 +1,42 @@ +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 = _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) + } +} diff --git a/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/model/MemberProfile.kt b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/model/MemberProfile.kt new file mode 100644 index 00000000..9bd2726c --- /dev/null +++ b/clients/members-feature/src/commonMain/kotlin/at/mocode/clients/membersfeature/model/MemberProfile.kt @@ -0,0 +1,16 @@ +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 = emptyList() +) { + val fullName: String + get() = listOfNotNull(firstName, lastName).joinToString(" ").ifBlank { username ?: "" } +} 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 index d3fc85dc..bf33a797 100644 --- 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 @@ -4,4 +4,5 @@ sealed class AppScreen { data object Home : AppScreen() data object Login : AppScreen() data object Ping : AppScreen() + data object Profile : AppScreen() } diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt new file mode 100644 index 00000000..fd8f92f2 --- /dev/null +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt @@ -0,0 +1,32 @@ +package at.mocode.core.domain.serialization + +import kotlinx.datetime.LocalDate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Kotlinx Serialization serializer for kotlinx.datetime.LocalDate. + * Serializes as ISO-8601 date string (yyyy-MM-dd). + */ +object KotlinLocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("KotlinLocalDate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalDate) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): LocalDate { + val text = decoder.decodeString() + return try { + LocalDate.parse(text) + } catch (e: Exception) { + throw SerializationException("Invalid LocalDate format: '$text'", e) + } + } +} diff --git a/services/members/members-api/build.gradle.kts b/services/members/members-api/build.gradle.kts index 7791b4b9..b1b7ea89 100644 --- a/services/members/members-api/build.gradle.kts +++ b/services/members/members-api/build.gradle.kts @@ -2,8 +2,8 @@ plugins { // kotlin("jvm") // kotlin("plugin.spring") - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSpring) // KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block // und alle Spring-Boot-spezifischen Gradle-Tasks frei. @@ -16,8 +16,8 @@ plugins { dependencies { implementation(projects.platform.platformDependencies) - implementation(projects.members.membersDomain) - implementation(projects.members.membersApplication) + implementation(projects.services.members.membersDomain) + implementation(projects.services.members.membersApplication) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) implementation(projects.infrastructure.messaging.messagingClient) diff --git a/services/members/members-application/build.gradle.kts b/services/members/members-application/build.gradle.kts index f9921c68..5c587869 100644 --- a/services/members/members-application/build.gradle.kts +++ b/services/members/members-application/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - implementation(projects.members.membersDomain) + implementation(projects.services.members.membersDomain) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) implementation(projects.infrastructure.messaging.messagingClient) diff --git a/settings.gradle.kts b/settings.gradle.kts index 397b6d24..b932bdd5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,39 +63,18 @@ include(":clients:ping-feature") include(":clients:auth-feature") include(":clients:shared:common-ui") include(":clients:shared:navigation") +include(":clients:members-feature") // Documentation module include(":docs") /* // Business modules (temporarily disabled - require multiplatform configuration updates) -// Members modules -include(":members:members-domain") -include(":members:members-application") -include(":members:members-infrastructure") -include(":members:members-api") -include(":members:members-service") - -// Horses modules -include(":horses:horses-domain") -include(":horses:horses-application") -include(":horses:horses-infrastructure") -include(":horses:horses-api") -include(":horses:horses-service") - -// Events modules -include(":events:events-domain") -include(":events:events-application") -include(":events:events-infrastructure") -include(":events:events-api") -include(":events:events-service") - -// Masterdata modules -include(":masterdata:masterdata-domain") -include(":masterdata:masterdata-application") -include(":masterdata:masterdata-infrastructure") -include(":masterdata:masterdata-api") -include(":masterdata:masterdata-service") - -// Note: These modules need multiplatform configuration updates to work with current KMP/WASM setup +// Note: We enable only the Members modules needed for API contracts to support the Members client feature. */ +// Members modules are currently disabled to keep the client build lean. +// We consume the Members REST API from the client without compiling backend modules here. +// include(":services:members:members-domain") +// include(":services:members:members-application") +// include(":services:members:members-api") +// other business modules remain disabled