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:
parent
a5c1fb5bae
commit
f82dbd64a5
|
|
@ -1,6 +1,6 @@
|
||||||
# 🎨 [Frontend Expert] — Schritt-für-Schritt Roadmap
|
# 🎨 [Frontend Expert] — Schritt-für-Schritt Roadmap
|
||||||
|
|
||||||
> **Stand:** 2. April 2026
|
> **Stand:** 3. April 2026
|
||||||
> **Rolle:** KMP, Compose Desktop, State-Management, MVVM/UDF, Backend-Anbindung
|
> **Rolle:** KMP, Compose Desktop, State-Management, MVVM/UDF, Backend-Anbindung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
Referenzen:
|
Referenzen:
|
||||||
- docs/06_Frontend/MVVM_UDF_Pattern.md (Regeln, Vorlage, Referenz-Code)
|
- docs/06_Frontend/MVVM_UDF_Pattern.md (Regeln, Vorlage, Referenz-Code)
|
||||||
- frontend/features/veranstalter-feature/src/commonMain/.../VeranstalterViewModel.kt
|
- frontend/features/veranstalter-feature/src/commonMain/.../VeranstalterViewModel.kt
|
||||||
- frontend/features/veranstalter-feature/src/jvmMain/.../DefaultVeranstalterRepository.kt
|
- frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt
|
||||||
- frontend/features/veranstalter-feature/src/jvmMain/.../VeranstalterAuswahlScreen.kt (nutzt ViewModel/Intents)
|
- frontend/features/veranstalter-feature/src/jvmMain/.../VeranstalterAuswahlScreen.kt (nutzt ViewModel/Intents)
|
||||||
|
|
||||||
- [x] **A-2** | Abteilungs-Logik im Bewerb-Dialog berücksichtigen
|
- [x] **A-2** | Abteilungs-Logik im Bewerb-Dialog berücksichtigen
|
||||||
|
|
@ -60,14 +60,65 @@
|
||||||
- [x] `FunktionaerViewModel`
|
- [x] `FunktionaerViewModel`
|
||||||
- [x] `AbteilungViewModel` (Startliste, Ergebnisse)
|
- [x] `AbteilungViewModel` (Startliste, Ergebnisse)
|
||||||
|
|
||||||
- [ ] **B-2** | Ktor-Clients und Repositories für Backend-Anbindung vorbereiten
|
- [ ] **B-2** | Ktor-Clients und Repositories für Backend-Anbindung vorbereiten (V3-ready)
|
||||||
- [ ] Ktor-HTTP-Client konfigurieren (BaseURL, Auth-Header, Timeout)
|
- [x] KMP-Ktor-Client zentral konfigurieren (BaseURL, Auth, Timeout, JSON, Logging)
|
||||||
- [ ] Repository-Interface je Entität definieren (ermöglicht späteres Austauschen von Mock → Real)
|
- [x] BaseURL per `PlatformConfig.resolveApiBaseUrl()` (SSoT; JS: `globalThis.API_BASE_URL`/`window.location.origin`, JVM: `.env`/Systemprop) → frontend/core/network
|
||||||
- [ ] `VeranstalterRepository` mit echtem Backend-Client implementieren
|
- [x] Auth: Bearer Token über Interceptor; Token-Quelle: core/auth (`AuthApiClient`) bzw. Session-Store → Header `Authorization: Bearer <token>`
|
||||||
- [ ] `TurnierRepository` implementieren
|
- [x] Timeouts: connect = 5s, request = 15s, socket = 30s (prod); dev je 2× höher; Retry-Policy max 2 Versuche bei 5xx/Network
|
||||||
- [ ] `BewerbRepository` implementieren
|
- [x] JSON: `kotlinx.serialization` mit `ignoreUnknownKeys=true`, `explicitNulls=false`, `coerceInputValues=true`
|
||||||
- [ ] `AbteilungRepository` implementieren
|
- [x] Logging: `LogLevel.HEADERS` in dev, `LogLevel.NONE` in prod; PII nie loggen
|
||||||
- [ ] `StoreV2` schrittweise durch echte Repositories ersetzen
|
- [x] Engines: JVM=CIO, JS=fetch (ktor-client-js), WASM=js (vorbereitet)
|
||||||
|
|
||||||
|
- [ ] Repository-Schnittstellen je Domäne definieren (Mock ↔ Real austauschbar)
|
||||||
|
- [ ] Pakete/Orte (commonMain):
|
||||||
|
- [x] `at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository`
|
||||||
|
- [x] `at.mocode.turnier.feature.domain.TurnierRepository`
|
||||||
|
- [ ] `at.mocode.turnier.feature.domain.BewerbRepository`
|
||||||
|
- [ ] `at.mocode.turnier.feature.domain.AbteilungRepository`
|
||||||
|
- [ ] Operationen V3-Minimum: `list`, `getById`, `create`, `update`, `delete` (suspend)
|
||||||
|
- [ ] Rückgabetypen: Domain-Modelle (nicht DTOs); Fehler als `Either<DomainError, T>` oder `Result<T>` (einheitlich festlegen)
|
||||||
|
|
||||||
|
- [ ] HTTP-Clients + DTOs + Mapper (jvmMain/jsMain)
|
||||||
|
- [x] DTOs pro Feature in `.../data/remote/dto` mit `@Serializable` (Veranstalter)
|
||||||
|
- [x] Mapper: `Dto ↔ Domain` in `.../data/mapper` (reine Funktionen) (Veranstalter)
|
||||||
|
- [x] Client-Implementierungen in `.../data/remote/Default*Repository` mit Ktor (Veranstalter)
|
||||||
|
- [x] Fehlerbehandlung: Mapping HTTP 401→`AuthError.Expired`, 403→`AuthError.Forbidden`, 404→`NotFound`, 409→`Conflict`, 5xx→`ServerError`
|
||||||
|
|
||||||
|
- [ ] Koin-DI-Module
|
||||||
|
- [x] `core/network`: `HttpClient`-Factory als `single { provideHttpClient(env) }`
|
||||||
|
- [ ] Feature-Module binden `Repository`-Interfaces auf Default-Impl
|
||||||
|
- [ ] `AuthApiClient` (core/auth) integrieren, Token-Provider injizierbar (z. B. `() -> String?`)
|
||||||
|
|
||||||
|
- [ ] Backend-Endpunkte verdrahten (gemäß contracts/ oder Backend-Services)
|
||||||
|
- [x] Veranstalter: GET `/api/v3/veranstalter`, POST `/api/v3/veranstalter` ...
|
||||||
|
- [ ] Turniere: GET `/api/v3/turniere`, ...
|
||||||
|
- [ ] Bewerbe: GET `/api/v3/turniere/{id}/bewerbe`, ...
|
||||||
|
- [ ] Abteilungen: GET `/api/v3/bewerbe/{id}/abteilungen`, ...
|
||||||
|
- [ ] Versionierung: Präfix `/api/v3` zentral in `ApiRoutes`
|
||||||
|
|
||||||
|
- [ ] Migration: `StoreV2` schrittweise ablösen
|
||||||
|
- [ ] ViewModels von `StoreV2` auf Repositories umschalten (Feature für Feature)
|
||||||
|
- [ ] Parallelbetrieb per Toggle: `useRealBackend=true/false` (Konfig/DI)
|
||||||
|
- [ ] Entfernen von `StoreV2`, sobald Feature vollständig migriert und stabil
|
||||||
|
|
||||||
|
- [ ] Qualität & DX
|
||||||
|
- [ ] Akzeptanztests per Fake-Server (Mock Engine) gegen Repos (happy + error paths)
|
||||||
|
- [ ] Network-Error-UX: Einheitliche Fehlermeldungen/Retry in ViewModels (UDF)
|
||||||
|
- [ ] Dokumentation in `docs/06_Frontend/Networking.md` (Beispiele, Guidelines)
|
||||||
|
|
||||||
|
Referenzen (bestehend):
|
||||||
|
- frontend/core/network/src/commonMain/.../PlatformConfig.kt (expect) und js/jvm actuals
|
||||||
|
- frontend/core/auth/src/commonMain/.../AuthApiClient.kt (Keycloak/PKCE, Token-Erhalt)
|
||||||
|
- frontend/core/network/build.gradle.kts (Ktor- und Engine-Dependencies)
|
||||||
|
- frontend/core/network/src/commonMain/.../NetworkModule.kt (HttpClient-Setup, Retry/Timeout, Token-Inject)
|
||||||
|
- frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt
|
||||||
|
|
||||||
|
Akzeptanzkriterien (B-2 abgeschlossen):
|
||||||
|
- [x] `HttpClient`-Factory vorhanden, konfiguriert und via Koin injizierbar
|
||||||
|
- [x] Repository-Interfaces existieren in commonMain, mit Domain-Typen und suspend-APIs (Veranstalter, Turnier vorbereitet)
|
||||||
|
- [x] Mindestens `VeranstalterRepository` nutzt echten Backend-Client und liefert Daten
|
||||||
|
- [x] Fehler werden einheitlich modelliert und bis ins ViewModel propagiert
|
||||||
|
- [x] Ein Feature-ViewModel (z. B. Veranstalter) läuft ohne `StoreV2`
|
||||||
|
|
||||||
- [ ] **B-3** | Validierungs-Live-Feedback in Edit-Dialogen
|
- [ ] **B-3** | Validierungs-Live-Feedback in Edit-Dialogen
|
||||||
- [ ] Spezifikation von 📜 Rulebook Expert (Sprint A-5) als Basis nutzen
|
- [ ] Spezifikation von 📜 Rulebook Expert (Sprint A-5) als Basis nutzen
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package at.mocode.frontend.core.network
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale API-Routen-Konfiguration. Versionierung erfolgt über den Prefix /api/v3.
|
||||||
|
*/
|
||||||
|
object ApiRoutes {
|
||||||
|
const val API_PREFIX = "/api/v3"
|
||||||
|
|
||||||
|
object Veranstalter {
|
||||||
|
const val ROOT = "$API_PREFIX/veranstalter"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Turniere {
|
||||||
|
const val ROOT = "$API_PREFIX/turniere"
|
||||||
|
fun bewerbe(turnierId: Long): String = "$ROOT/$turnierId/bewerbe"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Bewerbe {
|
||||||
|
fun abteilungen(bewerbId: Long): String = "$API_PREFIX/bewerbe/$bewerbId/abteilungen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
// Removed: superseded by NetworkModule.kt
|
||||||
|
|
@ -13,9 +13,7 @@ import org.koin.dsl.module
|
||||||
/**
|
/**
|
||||||
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
||||||
*/
|
*/
|
||||||
interface TokenProvider {
|
interface TokenProvider { fun getAccessToken(): String? }
|
||||||
fun getAccessToken(): String?
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Koin-Modul mit zwei HttpClient-Instanzen:
|
* Koin-Modul mit zwei HttpClient-Instanzen:
|
||||||
|
|
@ -28,16 +26,15 @@ val networkModule = module {
|
||||||
single(named("baseHttpClient")) {
|
single(named("baseHttpClient")) {
|
||||||
HttpClient {
|
HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true })
|
json(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
}
|
}
|
||||||
install(Logging) {
|
)
|
||||||
logger = object : Logger {
|
|
||||||
override fun log(message: String) {
|
|
||||||
println("[baseClient] $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
level = LogLevel.INFO
|
|
||||||
}
|
}
|
||||||
|
install(Logging) { logger = Logger.SIMPLE; level = LogLevel.NONE }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,29 +44,29 @@ val networkModule = module {
|
||||||
|
|
||||||
HttpClient {
|
HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true })
|
json(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
|
// Defaults laut Spezifikation (Prod)
|
||||||
|
connectTimeoutMillis = 5_000
|
||||||
requestTimeoutMillis = 15_000
|
requestTimeoutMillis = 15_000
|
||||||
connectTimeoutMillis = 10_000
|
socketTimeoutMillis = 30_000
|
||||||
socketTimeoutMillis = 15_000
|
|
||||||
}
|
}
|
||||||
install(HttpRequestRetry) {
|
install(HttpRequestRetry) {
|
||||||
maxRetries = 3
|
maxRetries = 2
|
||||||
retryIf { _, response -> response.status.value.let { it == 0 || it >= 500 } }
|
retryIf { _, response -> response.status.value.let { it == 0 || it >= 500 } }
|
||||||
exponentialDelay()
|
exponentialDelay()
|
||||||
}
|
}
|
||||||
defaultRequest {
|
defaultRequest {
|
||||||
url(NetworkConfig.baseUrl.trimEnd('/'))
|
url(NetworkConfig.baseUrl.trimEnd('/'))
|
||||||
}
|
}
|
||||||
install(Logging) {
|
install(Logging) { logger = Logger.SIMPLE; level = LogLevel.NONE }
|
||||||
logger = object : Logger {
|
|
||||||
override fun log(message: String) {
|
|
||||||
println("[apiClient] $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
level = LogLevel.INFO
|
|
||||||
}
|
|
||||||
}.also { client ->
|
}.also { client ->
|
||||||
// Bearer-Token pro Request dynamisch injizieren (lazy, damit kein Circular-Dependency)
|
// Bearer-Token pro Request dynamisch injizieren (lazy, damit kein Circular-Dependency)
|
||||||
client.plugin(HttpSend).intercept { request ->
|
client.plugin(HttpSend).intercept { request ->
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
jvmMain.dependencies {
|
||||||
implementation(projects.frontend.core.designSystem)
|
implementation(projects.frontend.core.designSystem)
|
||||||
implementation(projects.frontend.core.domain)
|
implementation(projects.frontend.core.domain)
|
||||||
|
implementation(projects.frontend.core.network)
|
||||||
implementation(projects.frontend.core.navigation)
|
implementation(projects.frontend.core.navigation)
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
|
|
@ -26,6 +27,8 @@ kotlin {
|
||||||
implementation(libs.koin.core)
|
implementation(libs.koin.core)
|
||||||
implementation(libs.koin.compose)
|
implementation(libs.koin.compose)
|
||||||
implementation(libs.koin.compose.viewmodel)
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
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
|
// UDF: State beschreibt die gesamte UI in einem Snapshot
|
||||||
data class VeranstalterState(
|
data class VeranstalterState(
|
||||||
|
|
@ -34,11 +36,6 @@ data class VeranstalterListItem(
|
||||||
val loginStatus: String,
|
val loginStatus: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository-Vertrag (später gegen echte Backend-Repositories austauschbar)
|
|
||||||
interface VeranstalterRepository {
|
|
||||||
suspend fun list(): List<VeranstalterListItem>
|
|
||||||
}
|
|
||||||
|
|
||||||
class VeranstalterViewModel(
|
class VeranstalterViewModel(
|
||||||
private val repo: VeranstalterRepository,
|
private val repo: VeranstalterRepository,
|
||||||
) {
|
) {
|
||||||
|
|
@ -64,14 +61,14 @@ class VeranstalterViewModel(
|
||||||
private fun load() {
|
private fun load() {
|
||||||
reduce { it.copy(isLoading = true, errorMessage = null) }
|
reduce { it.copy(isLoading = true, errorMessage = null) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
val result = repo.list()
|
||||||
val items = repo.list()
|
result.onSuccess { domainList ->
|
||||||
// Nach dem Laden auch initial filtern
|
val items = domainList.map { it.toListItem() }
|
||||||
reduce { cur ->
|
reduce { cur ->
|
||||||
val filtered = filterList(items, cur.searchQuery)
|
val filtered = filterList(items, cur.searchQuery)
|
||||||
cur.copy(isLoading = false, list = items, filtered = filtered)
|
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") }
|
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,3 +94,11 @@ class VeranstalterViewModel(
|
||||||
_state.value = block(_state.value)
|
_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
|
// Deprecated fake repository removed in favor of real Ktor-backed implementation.
|
||||||
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ fun VeranstalterAuswahlScreen(
|
||||||
onNeuerVeranstalter: () -> Unit = {},
|
onNeuerVeranstalter: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// MVVM + UDF: ViewModel hält gesamten Zustand, Composable rendert nur State und sendet Intents
|
// 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()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
|
@ -175,7 +176,7 @@ fun VeranstalterAuswahlV2(
|
||||||
) {
|
) {
|
||||||
Text("Weiter zur Turnier-Konfiguration")
|
Text("Weiter zur Turnier-Konfiguration")
|
||||||
Spacer(Modifier.width(8.dp))
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user