Implement MVVM + UDF: Add BewerbAnlegenViewModel, VeranstalterViewModel, and state management for Veranstalter and Bewerb workflows. Refactor existing Composables to use ViewModels and intents. Update Turnier UI for Bewerb creation with mandatory division logic, and add documentation for MVVM patterns and guidelines. Mark A-1 and A-2 as complete in the roadmap.

This commit is contained in:
2026-04-02 22:29:16 +02:00
parent a8bc82eb91
commit 5e4c292f0c
9 changed files with 469 additions and 81 deletions
+108
View File
@@ -0,0 +1,108 @@
### MVVM + UDF (Unidirectional Data Flow) — Referenz & Vorlage
Ziel: Alle ViewModels folgen einem klaren, einheitlichen Muster. Composables rendern nur `State` und senden `Intent`s. Business-Logik liegt im ViewModel, nicht in den UI-Funktionen.
#### Prinzipien
- Eine State-Klasse pro Screen/ViewModel (unveränderbar, vollständiger UI-Snapshot).
- Eine sealed Intent-Hierarchie pro ViewModel (alle Eingaben fließen darüber ein).
- Ein ViewModel, das:
- Intents entgegennimmt (`send(intent)`),
- State über einen `StateFlow` bereitstellt,
- Nebenläufigkeit intern kapselt (CoroutineScope),
- Repository-Aufrufe bündelt (keine direkten Store-/API-Aufrufe aus Composables).
#### Referenz-Implementierung: Veranstalter
Dateien:
- `frontend/features/veranstalter-feature/src/commonMain/.../VeranstalterViewModel.kt`
- `frontend/features/veranstalter-feature/src/jvmMain/.../DefaultVeranstalterRepository.kt`
- `frontend/features/veranstalter-feature/src/jvmMain/.../VeranstalterAuswahlScreen.kt` (verwendet das ViewModel)
State + Intent (verkürzt):
```kotlin
data class VeranstalterState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<VeranstalterListItem> = emptyList(),
val filtered: List<VeranstalterListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface VeranstalterIntent {
data object Load : VeranstalterIntent
data class SearchChanged(val query: String) : VeranstalterIntent
data class Select(val id: Long?) : VeranstalterIntent
data object ClearError : VeranstalterIntent
}
```
ViewModel (verkürzt):
```kotlin
class VeranstalterViewModel(private val repo: VeranstalterRepository) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(VeranstalterState(isLoading = true))
val state: StateFlow<VeranstalterState> = _state
init { send(VeranstalterIntent.Load) }
fun send(intent: VeranstalterIntent) {
when (intent) {
is VeranstalterIntent.Load -> load()
is VeranstalterIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is VeranstalterIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is VeranstalterIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
// load(), filter(), reduce() wie in Referenzdatei
}
```
Repository-Vertrag und JVM-Adapter (Prototyp, Fake-Store):
```kotlin
interface VeranstalterRepository { suspend fun list(): List<VeranstalterListItem> }
class DefaultVeranstalterRepository : VeranstalterRepository {
override suspend fun list(): List<VeranstalterListItem> = FakeVeranstalterStore
.all()
.map { it.toListItem() }
}
```
Composable-Verwendung (verkürzt):
```kotlin
@Composable
fun VeranstalterAuswahlScreen(onZurueck: () -> Unit, onWeiter: (Long) -> Unit) {
val vm = remember { VeranstalterViewModel(DefaultVeranstalterRepository()) }
val state by vm.state.collectAsState()
OutlinedTextField(
value = state.searchQuery,
onValueChange = { vm.send(VeranstalterIntent.SearchChanged(it)) },
)
LazyColumn {
items(state.filtered) { v ->
Row(Modifier.clickable { vm.send(VeranstalterIntent.Select(v.id)) }) { /* ... */ }
}
}
Button(enabled = state.selectedId != null) {
state.selectedId?.let { onWeiter(it) }
}
}
```
#### Regeln (verbindlich)
- MVVM + UDF ist Standard. Keine direkten `StoreV2`- oder API-Aufrufe in Composables (auch nicht in `onSaved`-Callbacks usw.).
- Kein lokaler `remember`-Zustand für Business-Logik. UI-Interaktionen senden ausschließlich Intents ans ViewModel.
- Persistenz/Netzwerk-Zugriffe laufen im Repository. Das ViewModel injiziert das Repository (später per DI).
- State ist die Single Source of Truth pro Screen.
#### Vorlage für neue ViewModels
1. `data class UiState(...)`
2. `sealed interface Intent { ... }`
3. `class XxxViewModel(repo: XxxRepository) { fun send(intent) ... }`
4. Composable: `val state by vm.state.collectAsState()` und `vm.send(...)` an Interaktionsstellen.
Diese Datei dient als Muster-Dokument für alle zukünftigen Frontend-Features.