meldestelle/docs/06_Frontend/MVVM_UDF_Pattern.md

4.1 KiB

MVVM + UDF (Unidirectional Data Flow) — Referenz & Vorlage

Ziel: Alle ViewModels folgen einem klaren, einheitlichen Muster. Composables rendern nur State und senden Intents. 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):

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):

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):

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):

@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.