120 lines
4.4 KiB
Markdown
120 lines
4.4 KiB
Markdown
---
|
|
type: Reference
|
|
status: ACTIVE
|
|
owner: Frontend Expert
|
|
last_update: 2026-04-03
|
|
---
|
|
|
|
### 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/kotlin/at/mocode/veranstalter/feature/presentation/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.
|
|
|
|
#### Siehe auch
|
|
- Weitere Beispiele: `ReiterViewModel`, `PferdeViewModel`, `PingViewModel` in `frontend/features/*/presentation/`
|
|
- Koin-Integration (VM-Erzeugung in Composables): `org.koin.compose.viewmodel.koinViewModel`
|
|
|
|
#### 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.
|