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
@@ -0,0 +1,99 @@
package at.mocode.veranstalter.feature.presentation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
// UDF: State beschreibt die gesamte UI in einem Snapshot
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,
)
// UDF: Absichten/Benutzer-Intents als einzige Eingabe ins ViewModel
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
}
// Leichtgewichtige Listendarstellung (UI-optimiert, unabhängig vom Domänenmodell)
data class VeranstalterListItem(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val loginStatus: String,
)
// Repository-Vertrag (später gegen echte Backend-Repositories austauschbar)
interface VeranstalterRepository {
suspend fun list(): List<VeranstalterListItem>
}
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 {
// Default: initial laden
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) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
// Nach dem Laden auch initial filtern
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
}
}
}
private fun filter() {
val cur = _state.value
val filtered = filterList(cur.list, cur.searchQuery)
reduce { it.copy(filtered = filtered) }
}
private fun filterList(list: List<VeranstalterListItem>, query: String): List<VeranstalterListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.oepsNummer.contains(q, ignoreCase = true) ||
it.ort.contains(q, ignoreCase = true)
}
}
private inline fun reduce(block: (VeranstalterState) -> VeranstalterState) {
_state.value = block(_state.value)
}
}