feat(masterdata): add ÖTO seed data, regulation validation tests, and profile module integration

- Introduced ÖTO 2026-compliant seed data (`V008__Seed_OETO_2026_Data.sql`) for tournament classes, license matrix, and age groups.
- Added `RegulationSeedVerificationTest` to validate repository queries and domain eligibility logic.
- Implemented a new `profile-feature` module covering user profile management and ZNS linking.
- Integrated the `profile-feature` into the desktop shell and frontend with Koin DI configuration.
- Extended CHANGELOG, ROADMAP, and architecture documentation to reflect related changes.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-30 16:14:08 +02:00
parent 2262826603
commit c5c1e96d25
13 changed files with 682 additions and 10 deletions
@@ -0,0 +1,52 @@
/**
* Dieses Modul kapselt die UI und Logik für die Profil-Verwaltung und den ZNS-Link.
*/
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 {
jvm()
sourceSets {
commonMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth)
implementation(projects.frontend.core.domain)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.ktor.client.common)
implementation(libs.bundles.compose.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(compose.uiTooling)
}
}
}
@@ -0,0 +1,101 @@
package at.mocode.frontend.features.profile.data
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.network.PlatformConfig
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
/**
* Client für die Kommunikation mit dem Identity-Service (Profile-API).
*/
class ProfileApiClient(
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager,
private val baseUrl: String = PlatformConfig.resolveApiBaseUrl()
) {
/**
* Ruft das eigene Profil ab.
*/
suspend fun getMyProfile(): ProfileDto? {
val token = authTokenManager.getToken() ?: return null
return try {
val response = httpClient.get("$baseUrl/api/v1/profiles/me") {
header(HttpHeaders.Authorization, "Bearer $token")
}
when (response.status) {
HttpStatusCode.OK -> {
response.body<ProfileDto>()
}
HttpStatusCode.NoContent, HttpStatusCode.NotFound -> {
null
}
else -> {
null
}
}
} catch (_: Exception) {
null
}
}
/**
* Verknüpft den User mit einer ZNS-Satznummer.
*/
suspend fun linkToZns(satznummer: String): ProfileDto? {
val token = authTokenManager.getToken() ?: return null
return try {
val response = httpClient.post("$baseUrl/api/v1/profiles/link/$satznummer") {
header(HttpHeaders.Authorization, "Bearer $token")
}
if (response.status.isSuccess()) {
response.body<ProfileDto>()
} else {
null
}
} catch (_: Exception) {
null
}
}
/**
* Aktualisiert Profildaten.
*/
suspend fun updateProfile(request: ProfileUpdateRequest): ProfileDto? {
val token = authTokenManager.getToken() ?: return null
return try {
val response = httpClient.put("$baseUrl/api/v1/profiles/me") {
header(HttpHeaders.Authorization, "Bearer $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
response.body<ProfileDto>()
} else {
null
}
} catch (_: Exception) {
null
}
}
}
@Serializable
data class ProfileUpdateRequest(
val logoUrl: String? = null,
val bio: String? = null,
val contactEmail: String? = null
)
@Serializable
data class ProfileDto(
val satznummer: String? = null,
val bio: String? = null,
val contactEmail: String? = null,
val logoUrl: String? = null
)
@@ -0,0 +1,11 @@
package at.mocode.frontend.features.profile.di
import at.mocode.frontend.features.profile.data.ProfileApiClient
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val profileModule = module {
singleOf(::ProfileApiClient)
single { ProfileViewModel(get()) }
}
@@ -0,0 +1,221 @@
package at.mocode.frontend.features.profile.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.LoadingIndicator
import at.mocode.frontend.core.designsystem.components.MeldestelleButton
import at.mocode.frontend.core.designsystem.components.MeldestelleTextField
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel,
modifier: Modifier = Modifier
) {
val uiState = viewModel.uiState
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = "Mein Profil",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 24.dp)
)
if (uiState.isLoading) {
LoadingIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else {
// Fehleranzeige
uiState.errorMessage?.let { error ->
ErrorMessage(
message = error,
onDismiss = { viewModel.clearError() }
)
}
// Erfolgsanzeige für Link
if (uiState.linkSuccess) {
SuccessMessage(
message = "ZNS-Verknüpfung erfolgreich!",
onDismiss = { viewModel.resetLinkStatus() }
)
}
val profile = uiState.profile
if (profile == null) {
// ZNS Link Section wenn kein Profil existiert
ZnsLinkSection(
isLinking = uiState.isLinking,
onLink = { satznummer -> viewModel.linkToZns(satznummer) }
)
} else {
// Profil Details
ProfileDetailsSection(
profile = profile,
onUpdate = { bio, email -> viewModel.updateProfile(bio, email) }
)
}
}
}
}
@Composable
fun ZnsLinkSection(
isLinking: Boolean,
onLink: (String) -> Unit
) {
var satznummer by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Link, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("ZNS Identität verknüpfen", style = MaterialTheme.typography.titleLarge)
}
Spacer(Modifier.height(8.dp))
Text(
"Verknüpfen Sie Ihren Account mit Ihrer offiziellen ZNS-Satznummer, um Nennungen abgeben zu können.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(16.dp))
MeldestelleTextField(
value = satznummer,
onValueChange = { satznummer = it },
label = "ZNS Satznummer",
placeholder = "z.B. 1234567",
modifier = Modifier.fillMaxWidth(),
enabled = !isLinking,
leadingIcon = Icons.Default.Badge
)
Spacer(Modifier.height(16.dp))
MeldestelleButton(
onClick = { onLink(satznummer) },
text = "Jetzt verknüpfen",
isLoading = isLinking,
modifier = Modifier.align(Alignment.End)
)
}
}
}
@Composable
fun ProfileDetailsSection(
profile: at.mocode.frontend.features.profile.data.ProfileDto,
onUpdate: (String?, String?) -> Unit
) {
var bio by remember(profile.bio) { mutableStateOf(profile.bio ?: "") }
var contactEmail by remember(profile.contactEmail) { mutableStateOf(profile.contactEmail ?: "") }
var isEditing by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Profildaten", style = MaterialTheme.typography.titleLarge)
TextButton(onClick = {
if (isEditing) onUpdate(bio, contactEmail)
isEditing = !isEditing
}) {
Text(if (isEditing) "Speichern" else "Bearbeiten")
}
}
Spacer(Modifier.height(16.dp))
DetailItem(label = "Satznummer", value = profile.satznummer ?: "Nicht verknüpft", icon = Icons.Default.Badge)
Spacer(Modifier.height(12.dp))
if (isEditing) {
MeldestelleTextField(
value = contactEmail,
onValueChange = { contactEmail = it },
label = "Kontakt E-Mail",
modifier = Modifier.fillMaxWidth(),
leadingIcon = Icons.Default.Email
)
Spacer(Modifier.height(12.dp))
MeldestelleTextField(
value = bio,
onValueChange = { bio = it },
label = "Info / Bio",
modifier = Modifier.fillMaxWidth(),
leadingIcon = Icons.Default.Info,
singleLine = false,
maxLines = 5
)
} else {
DetailItem(
label = "Kontakt E-Mail",
value = profile.contactEmail ?: "Nicht angegeben",
icon = Icons.Default.Email
)
Spacer(Modifier.height(12.dp))
DetailItem(label = "Info", value = profile.bio ?: "Keine Information hinterlegt", icon = Icons.Default.Info)
}
}
}
}
@Composable
fun DetailItem(label: String, value: String, icon: androidx.compose.ui.graphics.vector.ImageVector) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp))
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}
}
@Composable
fun ErrorMessage(message: String, onDismiss: () -> Unit) {
Snackbar(
modifier = Modifier.padding(bottom = 16.dp),
action = { TextButton(onClick = onDismiss) { Text("OK", color = MaterialTheme.colorScheme.inverseOnSurface) } },
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
) {
Text(message)
}
}
@Composable
fun SuccessMessage(message: String, onDismiss: () -> Unit) {
Snackbar(
modifier = Modifier.padding(bottom = 16.dp),
action = { TextButton(onClick = onDismiss) { Text("OK", color = MaterialTheme.colorScheme.inverseOnSurface) } },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.primary
) {
Text(message)
}
}
@@ -0,0 +1,88 @@
package at.mocode.frontend.features.profile.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.profile.data.ProfileApiClient
import at.mocode.frontend.features.profile.data.ProfileDto
import at.mocode.frontend.features.profile.data.ProfileUpdateRequest
import kotlinx.coroutines.launch
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: ProfileDto? = null,
val errorMessage: String? = null,
val isLinking: Boolean = false,
val linkSuccess: Boolean = false
)
class ProfileViewModel(
private val profileApiClient: ProfileApiClient
) : ViewModel() {
var uiState by mutableStateOf(ProfileUiState())
private set
init {
loadProfile()
}
fun loadProfile() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
val profile = profileApiClient.getMyProfile()
uiState = uiState.copy(isLoading = false, profile = profile)
}
}
fun linkToZns(satznummer: String) {
if (satznummer.isBlank()) {
uiState = uiState.copy(errorMessage = "Satznummer darf nicht leer sein.")
return
}
viewModelScope.launch {
uiState = uiState.copy(isLinking = true, errorMessage = null, linkSuccess = false)
val profile = profileApiClient.linkToZns(satznummer)
uiState = if (profile != null) {
uiState.copy(
isLinking = false,
profile = profile,
linkSuccess = true
)
} else {
uiState.copy(
isLinking = false,
errorMessage = "Verknüpfung fehlgeschlagen. Bitte prüfen Sie die Satznummer."
)
}
}
}
fun updateProfile(bio: String?, contactEmail: String?) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
val profile = profileApiClient.updateProfile(
ProfileUpdateRequest(
bio = bio,
contactEmail = contactEmail
)
)
uiState = if (profile != null) {
uiState.copy(isLoading = false, profile = profile)
} else {
uiState.copy(isLoading = false, errorMessage = "Profil-Update fehlgeschlagen.")
}
}
}
fun clearError() {
uiState = uiState.copy(errorMessage = null)
}
fun resetLinkStatus() {
uiState = uiState.copy(linkSuccess = false)
}
}