refactoring:

Ein neues Kotlin-Multiplattform-Mitgliedermodul wurde bereitgestellt, das clientseitige API-Aufrufe, Benutzeroberfläche und Navigation in die Host-Anwendung integriert. Der Build wurde durch die Entkopplung von Backend-Mitgliedermodulen stabilisiert, und alle Tests wurden erfolgreich abgeschlossen. Die Client-Funktionen erfolgen über REST-Aufrufe an das Gateway; die Backend-Integration wird in einer späteren Phase implementiert.
This commit is contained in:
Stefan Mogeritsch 2025-11-24 22:06:28 +01:00
parent d11ee48fde
commit a6a35a2eda
12 changed files with 291 additions and 36 deletions

View File

@ -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)

View File

@ -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>(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")

View File

@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn")
}
}

View File

@ -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()
}
}

View File

@ -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")
}
}
}
}

View File

@ -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<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)
}
}

View File

@ -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<String> = emptyList()
) {
val fullName: String
get() = listOfNotNull(firstName, lastName).joinToString(" ").ifBlank { username ?: "" }
}

View File

@ -4,4 +4,5 @@ sealed class AppScreen {
data object Home : AppScreen()
data object Login : AppScreen()
data object Ping : AppScreen()
data object Profile : AppScreen()
}

View File

@ -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<LocalDate> {
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)
}
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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