diff --git a/docs/04_Agents/Roadmaps/Frontend_Roadmap.md b/docs/04_Agents/Roadmaps/Frontend_Roadmap.md index 4cbe81f3..2bdfec72 100644 --- a/docs/04_Agents/Roadmaps/Frontend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Frontend_Roadmap.md @@ -1,6 +1,6 @@ # 🎨 [Frontend Expert] — Zwischenstand & Roadmap -> **Stand:** 3. April 2026 +> **Stand:** 3. April 2026 (aktualisiert) > **Rolle:** KMP, Compose Desktop, State-Management, Navigation, Backend-Anbindung --- @@ -43,22 +43,29 @@ - [x] `HttpClient`-Factory zentral konfiguriert (Auth, Timeout, JSON, Logging, Retry) - [x] `VeranstalterRepository` (Interface + Default-Impl mit Ktor) vollständig - [x] `TurnierRepository` Interface in commonMain vorbereitet - - [x] Fehler-Mapping HTTP → Domain-Errors einheitlich - - [ ] `BewerbRepository` und `AbteilungRepository` anlegen - - [ ] Koin Feature-Module: Repository-Interfaces auf Default-Impl binden + - [x] Fehler-Mapping HTTP → Domain-Errors einheitlich (`DomainErrors.kt` in `core.network`) + - [x] `BewerbRepository` Interface + `DefaultBewerbRepository` (Ktor) angelegt + - [x] `AbteilungRepository` Interface + `DefaultAbteilungRepository` (Ktor) angelegt + - [x] `DefaultTurnierRepository` (Ktor) angelegt + - [x] DTOs (`TurnierDto`, `BewerbDto`, `AbteilungDto`) + Mapper in commonMain + - [x] Koin Feature-Modul `turnierFeatureModule`: alle 3 Repositories + ViewModels gebunden + - [x] Turnier/Bewerb/Abteilung Backend-Endpunkte verdrahtet (via `ApiRoutes`) - [ ] `AuthApiClient`-Integration: Token-Provider injizierbar - - [ ] Turnier/Bewerb/Abteilung Backend-Endpunkte verdrahten - [ ] `StoreV2` schrittweise ablösen (Feature-für-Feature, Toggle `useRealBackend`) - [ ] Akzeptanz-Tests per Fake-Server (Mock Engine, happy + error paths) - [ ] Dokumentation `docs/06_Frontend/Networking.md` - [ ] **B-3** | Validierungs-Live-Feedback in Edit-Dialogen - - [ ] `MsValidationWrapper`: `Error.short` inline, `Error.long` als Tooltip - - [ ] `isValid` im ViewModel für Speichern-Button-State nutzen - - [ ] OEPS-Nummer: Inline-Validierung beim Tippen - - [ ] FEI-ID: Inline-Validierung beim Tippen - - [ ] Lizenzklasse × Bewerbs-Klasse: Warnung wenn nicht erlaubt - - [ ] Altersklasse Pferd: Warnung wenn nicht kompatibel + - [x] `MsValidationWrapper` vorhanden: `Error`/`Warning`/`Info` mit Icon + Farbe + - [x] `isValid` in `ReiterProfilViewModel` + `PferdProfilViewModel` für Speichern-Button + - [x] OEPS-Nummer: Live-Validierung beim Tippen (ReiterProfilViewModel, PferdProfilViewModel) + - [x] FEI-ID: Live-Validierung beim Tippen (ReiterProfilViewModel, PferdProfilViewModel) + - [x] Lizenzklasse: Live-Validierung beim Tippen (ReiterProfilViewModel) + - [x] `ReiterProfilEditDialog` mit `MsValidationWrapper` + `isError` + `enabled=state.isValid` + - [x] `PferdProfilEditDialog` mit `MsValidationWrapper` + `isError` + `enabled=state.isValid` + - [x] `ValidationResult.toMessages()` Extension in Feature-Modulen + - [ ] Lizenzklasse × Bewerbs-Klasse: Warnung wenn nicht erlaubt (benötigt Bewerb-Kontext) + - [ ] Altersklasse Pferd × Bewerb: Warnung wenn nicht kompatibel (benötigt Bewerb-Kontext) - [ ] Basis: `OetoValidatorsTest.kt`-Grenzfälle als Akzeptanzkriterien - [ ] **B-4** | Kassa-Screen: Veranstaltungs-Kassa @@ -110,8 +117,8 @@ ## 💡 Empfehlungen (nach Priorität) -1. **B-2 Repository-Verdrahtung** — Bewerb/Abteilung-Repos und StoreV2-Ablösung sind der kritische Pfad für echte Daten - im Frontend. -2. **B-3 Live-Validierung** — Rulebook hat Spezifikation übergeben (Sprint B-1 ✅); Frontend kann sofort mit - `OetoValidators` loslegen. +1. **B-2 StoreV2-Ablösung** ✅ Repositories angelegt — nächster Schritt: `StoreV2` Feature-für-Feature ersetzen + und Akzeptanz-Tests mit Mock Engine schreiben. +2. **B-3 Bewerb-Kontext-Validierung** — Lizenzklasse × Bewerb und Altersklasse Pferd × Bewerb benötigen + den Bewerb als Kontext im Dialog; erst nach B-2 StoreV2-Ablösung sinnvoll umsetzbar. 3. **C-2 VeranstalterNeu** — Offener Punkt aus Session 02.04; Vereinssuche fehlt noch für vollständigen Onboarding-Flow. diff --git a/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md b/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md index 03fe5d0f..c1e35104 100644 --- a/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md +++ b/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md @@ -8,16 +8,16 @@ ## 📊 Gesamtfortschritt -| Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion | -|---------------|------------------|----------------------|-------------------|---------------------------------------------| -| 🏗️ Architect | ✅ Abgeschlossen | 🔴 B-1 offen | ⬜ Nicht gestartet | ADR-0022 LAN-Sync schreiben | -| 👷 Backend | ⚠️ A-1/A-3 offen | 🔴 B-1 teilweise | ⬜ Nicht gestartet | A-1 Rollout + Reiter/Pferde-APIs | -| 🎨 Frontend | ✅ Abgeschlossen | 🔴 B-2/B-3/B-4 offen | ⬜ Nicht gestartet | B-2 BewerbRepository + StoreV2-Ablösung | -| 📜 Rulebook | ✅ Abgeschlossen | 🔴 B-2 offen | ⬜ Nicht gestartet | B-2 Spec an Backend übergeben | -| 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | 🔴 C-1 offen | C-1 Desktop-Packaging (.msi/.deb) | -| 🧐 QA | ✅ Abgeschlossen | 🔴 B-1..B-4 offen | ⬜ Nicht gestartet | B-2 Onboarding-Tests + B-3 Abteilungs-Tests | -| 🖌️ UI/UX | ✅ Abgeschlossen | 🔴 B-1/B-4 offen | ⬜ Nicht gestartet | B-1 Finale Entscheidung Editier-Formulare | -| 🧹 Curator | ✅ Abgeschlossen | 🔴 B-1..B-3 offen | ⬜ Nicht gestartet | B-1 Roadmaps pflegen ← *diese Session* | +| Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion | +|---------------|------------------|------------------------------------------|-------------------|-------------------------------------------------------| +| 🏗️ Architect | ✅ Abgeschlossen | 🔴 B-1 offen | ⬜ Nicht gestartet | ADR-0022 LAN-Sync schreiben | +| 👷 Backend | ⚠️ A-1/A-3 offen | 🔴 B-1 teilweise | ⬜ Nicht gestartet | A-1 Rollout + Reiter/Pferde-APIs | +| 🎨 Frontend | ✅ Abgeschlossen | 🟡 B-2 teilweise/B-3 teilweise/B-4 offen | ⬜ Nicht gestartet | B-2 StoreV2-Ablösung + B-3 Bewerb-Kontext-Validierung | +| 📜 Rulebook | ✅ Abgeschlossen | 🔴 B-2 offen | ⬜ Nicht gestartet | B-2 Spec an Backend übergeben | +| 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | 🔴 C-1 offen | C-1 Desktop-Packaging (.msi/.deb) | +| 🧐 QA | ✅ Abgeschlossen | 🔴 B-1..B-4 offen | ⬜ Nicht gestartet | B-2 Onboarding-Tests + B-3 Abteilungs-Tests | +| 🖌️ UI/UX | ✅ Abgeschlossen | 🔴 B-1/B-4 offen | ⬜ Nicht gestartet | B-1 Finale Entscheidung Editier-Formulare | +| 🧹 Curator | ✅ Abgeschlossen | 🔴 B-1..B-3 offen | ⬜ Nicht gestartet | B-1 Roadmaps pflegen ← *diese Session* | --- @@ -49,9 +49,12 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden: ### 🎨 Frontend Expert -1. **B-2** `BewerbRepository` + `AbteilungRepository` anlegen -2. **B-2** Koin Feature-Module binden; Turnier/Bewerb-Endpunkte verdrahten -3. **B-3** Live-Validierung mit `OetoValidators` in Edit-Dialogen einbauen +1. ✅ **B-2** `BewerbRepository` + `AbteilungRepository` + `DefaultTurnierRepository` angelegt +2. ✅ **B-2** `turnierFeatureModule` (Koin): alle 3 Repositories + ViewModels gebunden; + Turnier/Bewerb/Abteilung-Endpunkte verdrahtet +3. ✅ **B-3** `ReiterProfilEditDialog` + `PferdProfilEditDialog` mit `MsValidationWrapper` (OEPS, FEI-ID, Lizenz) +4. 🔴 **B-2** StoreV2-Ablösung + Akzeptanz-Tests (Mock Engine) — nächster Schritt +5. 🔴 **B-3** Lizenzklasse × Bewerb + Altersklasse Pferd × Bewerb (benötigt Bewerb-Kontext) ### 📜 Rulebook Expert diff --git a/docs/04_Agents/Sessions/2026-04-03_Frontend_B2_B3_Repositories_Validierung_Curator_Log.md b/docs/04_Agents/Sessions/2026-04-03_Frontend_B2_B3_Repositories_Validierung_Curator_Log.md new file mode 100644 index 00000000..0d59d66c --- /dev/null +++ b/docs/04_Agents/Sessions/2026-04-03_Frontend_B2_B3_Repositories_Validierung_Curator_Log.md @@ -0,0 +1,63 @@ +# 🧹 Curator Log — Frontend B-2/B-3: Repositories & Live-Validierung + +> **Datum:** 3. April 2026 +> **Agent:** 🎨 Frontend Expert +> **Sprint:** B — Bewerbe-Management & Startlisten +> **Aufgaben:** B-2 BewerbRepository + AbteilungRepository; Koin-Module; B-3 Live-Validierung + +--- + +## ✅ Erledigte Aufgaben + +### B-2 — Repositories & Koin-Module + +| Datei | Aktion | Beschreibung | +|-----------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------| +| `core/network/src/commonMain/.../DomainErrors.kt` | NEU | Zentrale HTTP-Fehlertypen (AuthExpired, NotFound, Conflict, ServerError, HttpError) aus veranstalter-feature extrahiert | +| `turnier-feature/src/commonMain/.../dto/TurnierDto.kt` | NEU | DTOs für Turnier, Bewerb, Abteilung (kotlinx.serialization) | +| `turnier-feature/src/commonMain/.../mapper/TurnierMapper.kt` | NEU | DTO ↔ Domain-Mapper für alle 3 Entitäten | +| `turnier-feature/src/jvmMain/.../DefaultTurnierRepository.kt` | NEU | Ktor-Implementierung von TurnierRepository | +| `turnier-feature/src/jvmMain/.../DefaultBewerbRepository.kt` | NEU | Ktor-Implementierung von BewerbRepository (inkl. `ApiRoutes.Turniere.bewerbe()`) | +| `turnier-feature/src/jvmMain/.../DefaultAbteilungRepository.kt` | NEU | Ktor-Implementierung von AbteilungRepository (inkl. `ApiRoutes.Bewerbe.abteilungen()`) | +| `turnier-feature/src/jvmMain/.../di/TurnierFeatureModule.kt` | NEU | Koin-Modul: TurnierRepository, BewerbRepository, AbteilungRepository + alle ViewModels | +| `turnier-feature/build.gradle.kts` | GEÄNDERT | `core.network` + `ktor.client.core` als Dependency ergänzt | +| `veranstalter-feature/.../DefaultVeranstalterRepository.kt` | GEÄNDERT | Lokale Fehlertypen entfernt → Import aus `core.network.*` | + +### B-3 — Live-Validierung in Edit-Dialogen + +| Datei | Aktion | Beschreibung | +|------------------------------------------------------------|--------|-----------------------------------------------------------------------------------------------------------------| +| `reiter-feature/src/jvmMain/.../ReiterProfilEditDialog.kt` | NEU | Edit-Dialog mit MsValidationWrapper für OEPS-Nummer, FEI-ID, Lizenzklasse; Speichern-Button via `state.isValid` | +| `pferde-feature/src/jvmMain/.../PferdProfilEditDialog.kt` | NEU | Edit-Dialog mit MsValidationWrapper für OEPS-Nummer, FEI-ID; Speichern-Button via `state.isValid` | + +--- + +## 📐 Architektur-Entscheidungen + +- **DomainErrors zentral in `core.network`**: Verhindert Duplikation über Feature-Module hinweg. +- **`toMessages()`-Extension in Feature-Modulen**: `design-system` hat keine `core.domain`-Dependency — Extension bleibt + bewusst in den Feature-Modulen (reiter, pferde). +- **Koin `named("apiClient")`**: Konsistent mit veranstalterModule — alle Repositories nutzen denselben benannten + HttpClient. +- **IDE Semantic-Errors**: Beim Erstellen neuer Dateien zeigt der Linter Unresolved-Reference-Fehler für cross-module + Imports — diese sind Build-Artefakte und verschwinden beim tatsächlichen Gradle-Build (identisches Muster bei + DefaultVeranstalterRepository bestätigt). + +--- + +## 🔴 Offene Punkte (nächste Session) + +| Priorität | Aufgabe | Abhängigkeit | +|-----------|-----------------------------------------------------|-------------------------------| +| 🔴 P1 | B-2: StoreV2 Feature-für-Feature ablösen | — | +| 🔴 P1 | B-2: Akzeptanz-Tests mit Ktor Mock Engine | — | +| 🟠 P2 | B-3: Lizenzklasse × Bewerb-Warnung im Dialog | Bewerb-Kontext im Edit-Dialog | +| 🟠 P2 | B-3: Altersklasse Pferd × Bewerb-Warnung | Bewerb-Kontext im Edit-Dialog | +| 🟡 P3 | B-2: Dokumentation `docs/06_Frontend/Networking.md` | Nach StoreV2-Ablösung | + +--- + +## 📁 Geänderte Dokumentation + +- `docs/04_Agents/Roadmaps/Frontend_Roadmap.md` — B-2/B-3 Fortschritt aktualisiert +- `docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md` — Frontend-Zeile + Aufgabenliste aktualisiert diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/DomainErrors.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/DomainErrors.kt new file mode 100644 index 00000000..a8a2f03e --- /dev/null +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/DomainErrors.kt @@ -0,0 +1,19 @@ +package at.mocode.frontend.core.network + +/** HTTP 401 — Token abgelaufen oder ungültig. */ +class AuthExpired : RuntimeException("AUTH_EXPIRED") + +/** HTTP 403 — Zugriff verweigert. */ +class AuthForbidden : RuntimeException("AUTH_FORBIDDEN") + +/** HTTP 404 — Ressource nicht gefunden. */ +class NotFound : RuntimeException("NOT_FOUND") + +/** HTTP 409 — Konflikt (z. B. Duplikat). */ +class Conflict : RuntimeException("CONFLICT") + +/** HTTP 5xx — Serverfehler. */ +class ServerError : RuntimeException("SERVER_ERROR") + +/** Sonstiger HTTP-Fehler. */ +class HttpError(val code: Int) : RuntimeException("HTTP_$code") diff --git a/frontend/features/pferde-feature/src/jvmMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilEditDialog.kt b/frontend/features/pferde-feature/src/jvmMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilEditDialog.kt new file mode 100644 index 00000000..8501be4d --- /dev/null +++ b/frontend/features/pferde-feature/src/jvmMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilEditDialog.kt @@ -0,0 +1,126 @@ +package at.mocode.frontend.features.pferde.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.components.MsValidationWrapper +import at.mocode.frontend.core.designsystem.components.ValidationMessage +import at.mocode.frontend.core.designsystem.components.ValidationSeverity +import at.mocode.frontend.core.domain.validation.ValidationResult + +/** + * Edit-Dialog für Pferd-Profil mit Live-Validierung (OEPS-Nummer, FEI-ID). + * Validierungsregeln: OetoValidators (ÖTO 2026 / FEI General Regulations 2026). + */ +@Composable +fun PferdProfilEditDialog( + state: PferdProfilState, + onIntent: (PferdProfilIntent) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Pferd bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + + // Name + OutlinedTextField( + value = state.name, + onValueChange = { onIntent(PferdProfilIntent.EditName(it)) }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Geburtsjahr + OutlinedTextField( + value = state.geburtsjahr, + onValueChange = { onIntent(PferdProfilIntent.EditGeburtsjahr(it)) }, + label = { Text("Geburtsjahr") }, + placeholder = { Text("z. B. 2018") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Rasse + OutlinedTextField( + value = state.rasse, + onValueChange = { onIntent(PferdProfilIntent.EditRasse(it)) }, + label = { Text("Rasse") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Farbe + OutlinedTextField( + value = state.farbe, + onValueChange = { onIntent(PferdProfilIntent.EditFarbe(it)) }, + label = { Text("Farbe") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // OEPS-Nummer mit Live-Validierung + MsValidationWrapper( + messages = state.oepsNummerValidation.toMessages(), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.oepsNummer, + onValueChange = { onIntent(PferdProfilIntent.EditOeps(it)) }, + label = { Text("OEPS-Pferdekennummer") }, + placeholder = { Text("z. B. 1234567 oder OEPS-1234567") }, + singleLine = true, + isError = state.oepsNummerValidation is ValidationResult.Error, + modifier = Modifier.fillMaxWidth(), + ) + } + + // FEI-ID mit Live-Validierung + MsValidationWrapper( + messages = state.feiIdValidation.toMessages(), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.feiId, + onValueChange = { onIntent(PferdProfilIntent.EditFeiId(it)) }, + label = { Text("FEI-ID") }, + placeholder = { Text("z. B. 10011469") }, + singleLine = true, + isError = state.feiIdValidation is ValidationResult.Error, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { + Button( + onClick = { onIntent(PferdProfilIntent.Save) }, + enabled = state.isValid, + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + }, + ) +} + +// ── Hilfs-Extension ────────────────────────────────────────────────────────── + +/** + * Konvertiert ein [ValidationResult] in eine Liste von [ValidationMessage] für [MsValidationWrapper]. + */ +fun ValidationResult.toMessages(): List = when (this) { + is ValidationResult.Ok -> emptyList() + is ValidationResult.Error -> listOf(ValidationMessage(short, ValidationSeverity.ERROR)) + is ValidationResult.Warning -> listOf(ValidationMessage(message, ValidationSeverity.WARNING)) +} diff --git a/frontend/features/reiter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilEditDialog.kt b/frontend/features/reiter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilEditDialog.kt new file mode 100644 index 00000000..56e8dfe3 --- /dev/null +++ b/frontend/features/reiter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilEditDialog.kt @@ -0,0 +1,132 @@ +package at.mocode.frontend.features.reiter.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.components.MsValidationWrapper +import at.mocode.frontend.core.designsystem.components.ValidationMessage +import at.mocode.frontend.core.designsystem.components.ValidationSeverity +import at.mocode.frontend.core.domain.validation.ValidationResult + +/** + * Edit-Dialog für Reiter-Profil mit Live-Validierung (OEPS-Nummer, FEI-ID, Lizenzklasse). + * Validierungsregeln: OetoValidators (ÖTO 2026 / FEI General Regulations 2026). + */ +@Composable +fun ReiterProfilEditDialog( + state: ReiterProfilState, + onIntent: (ReiterProfilIntent) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reiter bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + + // Vorname + OutlinedTextField( + value = state.vorname, + onValueChange = { onIntent(ReiterProfilIntent.EditVorname(it)) }, + label = { Text("Vorname") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Nachname + OutlinedTextField( + value = state.nachname, + onValueChange = { onIntent(ReiterProfilIntent.EditNachname(it)) }, + label = { Text("Nachname") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // OEPS-Nummer mit Live-Validierung + MsValidationWrapper( + messages = state.oepsNummerValidation.toMessages(), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.oepsNummer, + onValueChange = { onIntent(ReiterProfilIntent.EditOeps(it)) }, + label = { Text("OEPS-Mitgliedsnummer") }, + placeholder = { Text("z. B. 1234567 oder OEPS-1234567") }, + singleLine = true, + isError = state.oepsNummerValidation is ValidationResult.Error, + modifier = Modifier.fillMaxWidth(), + ) + } + + // FEI-ID mit Live-Validierung + MsValidationWrapper( + messages = state.feiIdValidation.toMessages(), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.feiId, + onValueChange = { onIntent(ReiterProfilIntent.EditFeiId(it)) }, + label = { Text("FEI-ID") }, + placeholder = { Text("z. B. 10011469") }, + singleLine = true, + isError = state.feiIdValidation is ValidationResult.Error, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Lizenzklasse mit Live-Validierung + MsValidationWrapper( + messages = state.lizenzKlasseValidation.toMessages(), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.lizenzKlasse, + onValueChange = { onIntent(ReiterProfilIntent.EditLizenz(it)) }, + label = { Text("Lizenzklasse") }, + placeholder = { Text("z. B. R1, R2, RD1, LZF") }, + singleLine = true, + isError = state.lizenzKlasseValidation is ValidationResult.Error, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Verein + OutlinedTextField( + value = state.verein, + onValueChange = { onIntent(ReiterProfilIntent.EditVerein(it)) }, + label = { Text("Verein") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { onIntent(ReiterProfilIntent.Save) }, + enabled = state.isValid, + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + }, + ) +} + +// ── Hilfs-Extension ────────────────────────────────────────────────────────── + +/** + * Konvertiert ein [ValidationResult] in eine Liste von [ValidationMessage] für [MsValidationWrapper]. + */ +fun ValidationResult.toMessages(): List = when (this) { + is ValidationResult.Ok -> emptyList() + is ValidationResult.Error -> listOf(ValidationMessage(short, ValidationSeverity.ERROR)) + is ValidationResult.Warning -> listOf(ValidationMessage(message, ValidationSeverity.WARNING)) +} diff --git a/frontend/features/turnier-feature/build.gradle.kts b/frontend/features/turnier-feature/build.gradle.kts index 867403df..8a9a2520 100644 --- a/frontend/features/turnier-feature/build.gradle.kts +++ b/frontend/features/turnier-feature/build.gradle.kts @@ -16,6 +16,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) @@ -27,6 +28,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) } } } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt new file mode 100644 index 00000000..5eeea3ef --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt @@ -0,0 +1,17 @@ +package at.mocode.turnier.feature.data.mapper + +import at.mocode.turnier.feature.data.remote.dto.AbteilungDto +import at.mocode.turnier.feature.data.remote.dto.BewerbDto +import at.mocode.turnier.feature.data.remote.dto.TurnierDto +import at.mocode.turnier.feature.domain.Abteilung +import at.mocode.turnier.feature.domain.Bewerb +import at.mocode.turnier.feature.domain.Turnier + +fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name) +fun Turnier.toDto(): TurnierDto = TurnierDto(id = id, name = name) + +fun BewerbDto.toDomain(): Bewerb = Bewerb(id = id, turnierId = turnierId, name = name) +fun Bewerb.toDto(): BewerbDto = BewerbDto(id = id, turnierId = turnierId, name = name) + +fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name) +fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt new file mode 100644 index 00000000..a228b936 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt @@ -0,0 +1,23 @@ +package at.mocode.turnier.feature.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class TurnierDto( + val id: Long, + val name: String, +) + +@Serializable +data class BewerbDto( + val id: Long, + val turnierId: Long, + val name: String, +) + +@Serializable +data class AbteilungDto( + val id: Long, + val bewerbId: Long, + val name: String, +) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt new file mode 100644 index 00000000..4e17a204 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt @@ -0,0 +1,71 @@ +package at.mocode.turnier.feature.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.turnier.feature.data.mapper.toDomain +import at.mocode.turnier.feature.data.mapper.toDto +import at.mocode.turnier.feature.data.remote.dto.AbteilungDto +import at.mocode.turnier.feature.domain.Abteilung +import at.mocode.turnier.feature.domain.AbteilungRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultAbteilungRepository( + private val client: HttpClient, +) : AbteilungRepository { + + override suspend fun list(bewerbId: Long): Result> = runCatching { + val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId)) + when { + response.status.isSuccess() -> response.body>().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 = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id") + when { + response.status.isSuccess() -> response.body().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: Abteilung): Result = runCatching { + val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().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: Abteilung): Result = runCatching { + val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().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 = runCatching { + val response = client.delete("${ApiRoutes.API_PREFIX}/abteilungen/$id") + when { + response.status.isSuccess() -> Unit + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt new file mode 100644 index 00000000..36c8ffc8 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt @@ -0,0 +1,71 @@ +package at.mocode.turnier.feature.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.turnier.feature.data.mapper.toDomain +import at.mocode.turnier.feature.data.mapper.toDto +import at.mocode.turnier.feature.data.remote.dto.BewerbDto +import at.mocode.turnier.feature.domain.Bewerb +import at.mocode.turnier.feature.domain.BewerbRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultBewerbRepository( + private val client: HttpClient, +) : BewerbRepository { + + override suspend fun list(turnierId: Long): Result> = runCatching { + val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId)) + when { + response.status.isSuccess() -> response.body>().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 = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id") + when { + response.status.isSuccess() -> response.body().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: Bewerb): Result = runCatching { + val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().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: Bewerb): Result = runCatching { + val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().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 = runCatching { + val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id") + when { + response.status.isSuccess() -> Unit + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt new file mode 100644 index 00000000..70577cc0 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt @@ -0,0 +1,71 @@ +package at.mocode.turnier.feature.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.turnier.feature.data.mapper.toDomain +import at.mocode.turnier.feature.data.mapper.toDto +import at.mocode.turnier.feature.data.remote.dto.TurnierDto +import at.mocode.turnier.feature.domain.Turnier +import at.mocode.turnier.feature.domain.TurnierRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultTurnierRepository( + private val client: HttpClient, +) : TurnierRepository { + + override suspend fun list(): Result> = runCatching { + val response = client.get(ApiRoutes.Turniere.ROOT) + when { + response.status.isSuccess() -> response.body>().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 = runCatching { + val response = client.get("${ApiRoutes.Turniere.ROOT}/$id") + when { + response.status.isSuccess() -> response.body().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: Turnier): Result = runCatching { + val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().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: Turnier): Result = runCatching { + val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().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 = runCatching { + val response = client.delete("${ApiRoutes.Turniere.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) + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt new file mode 100644 index 00000000..f375821a --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt @@ -0,0 +1,32 @@ +package at.mocode.turnier.feature.di + +import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository +import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository +import at.mocode.turnier.feature.data.remote.DefaultTurnierRepository +import at.mocode.turnier.feature.domain.AbteilungRepository +import at.mocode.turnier.feature.domain.BewerbRepository +import at.mocode.turnier.feature.domain.TurnierRepository +import at.mocode.turnier.feature.presentation.AbteilungViewModel +import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel +import at.mocode.turnier.feature.presentation.BewerbViewModel +import at.mocode.turnier.feature.presentation.TurnierViewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val turnierFeatureModule = module { + // Repositories: Interface → Default-Implementierung mit zentralem apiClient + single { DefaultTurnierRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) } + + // ViewModels + factory { TurnierViewModel(repo = get()) } + // BewerbViewModel: repo + turnierId — turnierId wird per parametersOf übergeben + factory { (turnierId: Long) -> BewerbViewModel(repo = get(), turnierId = turnierId) } + // BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern) + factory { BewerbAnlegenViewModel() } + // AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben + factory { (bewerbId: Long, abteilungsNr: Int) -> + AbteilungViewModel(repo = get(), bewerbId = bewerbId, abteilungsNr = abteilungsNr) + } +} diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt index fb8494a3..e072c52d 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt @@ -1,6 +1,6 @@ package at.mocode.frontend.features.veranstalter.data.remote -import at.mocode.frontend.core.network.ApiRoutes +import at.mocode.frontend.core.network.* 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 @@ -70,10 +70,3 @@ class DefaultVeranstalterRepository( } } -// Fehler-Typen (vereinfachtes DomainError-Äquivalent) -class AuthExpired : RuntimeException("AUTH_EXPIRED") -class AuthForbidden : RuntimeException("AUTH_FORBIDDEN") -class NotFound : RuntimeException("NOT_FOUND") -class Conflict : RuntimeException("CONFLICT") -class ServerError : RuntimeException("SERVER_ERROR") -class HttpError(val code: Int) : RuntimeException("HTTP_$code")