Integrate Ktor HTTP clients and repositories for Veranstalter and Turnier features:

- Add `ApiRoutes` for central backend routing configuration.
- Implement `DefaultVeranstalterRepository` and `DefaultTurnierRepository` with Ktor clients.
- Add domain models (`Turnier`, `Bewerb`, `Abteilung`, `Veranstalter`) and respective repository interfaces.
- Replace fake VeranstalterRepository with real implementation.
- Update DI with `veranstalterModule` and HTTP client injection.
- Simplify TokenProvider and update HttpClient setup (timeouts, retries, logging).
- Mark roadmap tasks B-2 as partially complete.
This commit is contained in:
2026-04-03 01:09:30 +02:00
parent a5c1fb5bae
commit f82dbd64a5
17 changed files with 314 additions and 67 deletions
@@ -0,0 +1,15 @@
package at.mocode.turnier.feature.domain
data class Abteilung(
val id: Long,
val bewerbId: Long,
val name: String,
)
interface AbteilungRepository {
suspend fun list(bewerbId: Long): Result<List<Abteilung>>
suspend fun getById(id: Long): Result<Abteilung>
suspend fun create(model: Abteilung): Result<Abteilung>
suspend fun update(id: Long, model: Abteilung): Result<Abteilung>
suspend fun delete(id: Long): Result<Unit>
}
@@ -0,0 +1,15 @@
package at.mocode.turnier.feature.domain
data class Bewerb(
val id: Long,
val turnierId: Long,
val name: String,
)
interface BewerbRepository {
suspend fun list(turnierId: Long): Result<List<Bewerb>>
suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun delete(id: Long): Result<Unit>
}
@@ -0,0 +1,14 @@
package at.mocode.turnier.feature.domain
data class Turnier(
val id: Long,
val name: String,
)
interface TurnierRepository {
suspend fun list(): Result<List<Turnier>>
suspend fun getById(id: Long): Result<Turnier>
suspend fun create(model: Turnier): Result<Turnier>
suspend fun update(id: Long, model: Turnier): Result<Turnier>
suspend fun delete(id: Long): Result<Unit>
}
@@ -15,6 +15,7 @@ kotlin {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
@@ -26,6 +27,8 @@ kotlin {
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Ktor client for repository implementation
implementation(libs.ktor.client.core)
}
}
}
@@ -0,0 +1,20 @@
package at.mocode.frontend.features.veranstalter.data.mapper
import at.mocode.frontend.features.veranstalter.data.remote.dto.VeranstalterDto
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
fun VeranstalterDto.toDomain(): Veranstalter = Veranstalter(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus,
)
fun Veranstalter.toDto(): VeranstalterDto = VeranstalterDto(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus,
)
@@ -0,0 +1,12 @@
package at.mocode.frontend.features.veranstalter.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class VeranstalterDto(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val loginStatus: String,
)
@@ -0,0 +1,23 @@
package at.mocode.frontend.features.veranstalter.domain
/**
* Domänenmodell für Veranstalter (V3-Minimum für Listenansicht).
*/
data class Veranstalter(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val loginStatus: String,
)
/**
* Repository-Vertrag (commonMain) austauschbar zwischen Mock und Real.
*/
interface VeranstalterRepository {
suspend fun list(): Result<List<Veranstalter>>
suspend fun getById(id: Long): Result<Veranstalter>
suspend fun create(model: Veranstalter): Result<Veranstalter>
suspend fun update(id: Long, model: Veranstalter): Result<Veranstalter>
suspend fun delete(id: Long): Result<Unit>
}
@@ -6,6 +6,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// UDF: State beschreibt die gesamte UI in einem Snapshot
data class VeranstalterState(
@@ -34,11 +36,6 @@ data class VeranstalterListItem(
val loginStatus: String,
)
// Repository-Vertrag (später gegen echte Backend-Repositories austauschbar)
interface VeranstalterRepository {
suspend fun list(): List<VeranstalterListItem>
}
class VeranstalterViewModel(
private val repo: VeranstalterRepository,
) {
@@ -64,14 +61,14 @@ class VeranstalterViewModel(
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
// Nach dem Laden auch initial filtern
val result = repo.list()
result.onSuccess { domainList ->
val items = domainList.map { it.toListItem() }
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
}.onFailure { t ->
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
}
}
@@ -97,3 +94,11 @@ class VeranstalterViewModel(
_state.value = block(_state.value)
}
}
private fun DomainVeranstalter.toListItem() = VeranstalterListItem(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus,
)
@@ -0,0 +1,79 @@
package at.mocode.frontend.features.veranstalter.data.remote
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.frontend.features.veranstalter.data.mapper.toDomain
import at.mocode.frontend.features.veranstalter.data.mapper.toDto
import at.mocode.frontend.features.veranstalter.data.remote.dto.VeranstalterDto
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultVeranstalterRepository(
private val client: HttpClient,
) : VeranstalterRepository {
override suspend fun list(): Result<List<Veranstalter>> = runCatching {
val response = client.get(ApiRoutes.Veranstalter.ROOT)
when {
response.status.isSuccess() -> response.body<List<VeranstalterDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden
response.status.value >= 500 -> throw ServerError
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Veranstalter> = runCatching {
val response = client.get("${ApiRoutes.Veranstalter.ROOT}/$id")
when {
response.status.isSuccess() -> response.body<VeranstalterDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden
response.status.value >= 500 -> throw ServerError
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Veranstalter): Result<Veranstalter> = runCatching {
val response = client.post(ApiRoutes.Veranstalter.ROOT) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<VeranstalterDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict
response.status.value >= 500 -> throw ServerError
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Veranstalter): Result<Veranstalter> = runCatching {
val response = client.put("${ApiRoutes.Veranstalter.ROOT}/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<VeranstalterDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound
response.status == HttpStatusCode.Conflict -> throw Conflict
response.status.value >= 500 -> throw ServerError
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.Veranstalter.ROOT}/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound
response.status.value >= 500 -> throw ServerError
else -> throw HttpError(response.status.value)
}
}
}
// Fehler-Typen (vereinfachtes DomainError-Äquivalent)
object AuthExpired : RuntimeException("AUTH_EXPIRED")
object AuthForbidden : RuntimeException("AUTH_FORBIDDEN")
object NotFound : RuntimeException("NOT_FOUND")
object Conflict : RuntimeException("CONFLICT")
object ServerError : RuntimeException("SERVER_ERROR")
class HttpError(val code: Int) : RuntimeException("HTTP_$code")
@@ -0,0 +1,11 @@
package at.mocode.frontend.features.veranstalter.di
import at.mocode.frontend.features.veranstalter.data.remote.DefaultVeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import io.ktor.client.HttpClient
import org.koin.core.qualifier.named
import org.koin.dsl.module
val veranstalterModule = module {
single<VeranstalterRepository> { DefaultVeranstalterRepository(get(named("apiClient"))) }
}
@@ -1,23 +1 @@
package at.mocode.veranstalter.feature.presentation
import at.mocode.frontend.core.designsystem.models.LoginStatus
class DefaultVeranstalterRepository : VeranstalterRepository {
override suspend fun list(): List<VeranstalterListItem> {
// Aus Fake-Store lesen (Prototyp)
return FakeVeranstalterStore.all().map { it.toListItem() }
}
}
private fun LoginStatus.asLabel(): String = when (this) {
LoginStatus.AKTIV -> "AKTIV"
LoginStatus.AUSSTEHEND -> "AUSSTEHEND"
}
private fun VeranstalterUiModel.toListItem() = VeranstalterListItem(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus.asLabel(),
)
// Deprecated fake repository removed in favor of real Ktor-backed implementation.
@@ -44,7 +44,8 @@ fun VeranstalterAuswahlScreen(
onNeuerVeranstalter: () -> Unit = {},
) {
// MVVM + UDF: ViewModel hält gesamten Zustand, Composable rendert nur State und sendet Intents
val viewModel = remember { VeranstalterViewModel(DefaultVeranstalterRepository()) }
val repo: at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository = org.koin.compose.koinInject()
val viewModel = remember { VeranstalterViewModel(repo) }
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
@@ -175,7 +176,7 @@ fun VeranstalterAuswahlV2(
) {
Text("Weiter zur Turnier-Konfiguration")
Spacer(Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp))
}
}
}