Implement MVVM for all V3 screens: add ViewModels for Turniere, Bewerbe, Abteilungen, Pferde, Reiter, Vereins, and Funktionaer workflows. Update roadmap to mark B-1 tasks as complete.

This commit is contained in:
2026-04-03 00:21:09 +02:00
parent 48ffadaaa2
commit 2b3e2d8c1b
8 changed files with 748 additions and 8 deletions
@@ -0,0 +1,132 @@
package at.mocode.turnier.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
data class StartlistenEintrag(
val startNr: Int,
val reiterName: String,
val pferdeName: String,
)
data class ErgebnisEintrag(
val startNr: Int,
val punkte: Double?,
val rang: Int?,
)
data class AbteilungState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val startliste: List<StartlistenEintrag> = emptyList(),
val ergebnisse: List<ErgebnisEintrag> = emptyList(),
)
sealed interface AbteilungIntent {
data class LoadByBewerb(val bewerbId: Long, val abteilungsNr: Int) : AbteilungIntent
data object Refresh : AbteilungIntent
data class UpdateErgebnis(val startNr: Int, val punkte: Double?) : AbteilungIntent
data class ReorderStartliste(val fromIndex: Int, val toIndex: Int) : AbteilungIntent
data object Publish : AbteilungIntent
data object ClearError : AbteilungIntent
}
interface AbteilungRepository {
suspend fun loadStartliste(bewerbId: Long, abteilungsNr: Int): List<StartlistenEintrag>
suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List<ErgebnisEintrag>
suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?)
suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List<Int>)
suspend fun publish(bewerbId: Long, abteilungsNr: Int)
}
class AbteilungViewModel(
private val repo: AbteilungRepository,
private var bewerbId: Long,
private var abteilungsNr: Int,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(AbteilungState(isLoading = true))
val state: StateFlow<AbteilungState> = _state
init {
send(AbteilungIntent.LoadByBewerb(bewerbId, abteilungsNr))
}
fun send(intent: AbteilungIntent) {
when (intent) {
is AbteilungIntent.LoadByBewerb -> {
bewerbId = intent.bewerbId
abteilungsNr = intent.abteilungsNr
load()
}
is AbteilungIntent.Refresh -> load()
is AbteilungIntent.UpdateErgebnis -> updateErgebnis(intent.startNr, intent.punkte)
is AbteilungIntent.ReorderStartliste -> reorder(intent.fromIndex, intent.toIndex)
is AbteilungIntent.Publish -> publish()
is AbteilungIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val start = repo.loadStartliste(bewerbId, abteilungsNr)
val erg = repo.loadErgebnisse(bewerbId, abteilungsNr)
reduce { it.copy(isLoading = false, startliste = start, ergebnisse = erg) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun updateErgebnis(startNr: Int, punkte: Double?) {
scope.launch {
try {
repo.saveErgebnis(bewerbId, abteilungsNr, startNr, punkte)
// Lokale Spiegelung
val newErg = state.value.ergebnisse.toMutableList()
val idx = newErg.indexOfFirst { it.startNr == startNr }
if (idx >= 0) newErg[idx] = newErg[idx].copy(punkte = punkte) else newErg += ErgebnisEintrag(startNr, punkte, null)
reduce { it.copy(ergebnisse = newErg) }
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private fun reorder(fromIndex: Int, toIndex: Int) {
val list = state.value.startliste.toMutableList()
if (fromIndex in list.indices && toIndex in list.indices) {
val item = list.removeAt(fromIndex)
list.add(toIndex, item)
reduce { it.copy(startliste = list) }
scope.launch {
try {
repo.saveStartlistenOrder(bewerbId, abteilungsNr, list.map { it.startNr })
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern der Reihenfolge") }
}
}
}
}
private fun publish() {
scope.launch {
try {
repo.publish(bewerbId, abteilungsNr)
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Veröffentlichen") }
}
}
}
private inline fun reduce(block: (AbteilungState) -> AbteilungState) {
_state.value = block(_state.value)
}
}
@@ -0,0 +1,130 @@
package at.mocode.turnier.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
data class BewerbListItem(
val id: Long,
val tag: String,
val platz: Int,
val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
)
data class BewerbState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<BewerbListItem> = emptyList(),
val filtered: List<BewerbListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
)
sealed interface BewerbIntent {
data object Load : BewerbIntent
data object Refresh : BewerbIntent
data class SearchChanged(val query: String) : BewerbIntent
data class Select(val id: Long?) : BewerbIntent
data object ClearError : BewerbIntent
// Delegation an Dialog-VM
data object OpenDialog : BewerbIntent
data object CloseDialog : BewerbIntent
data class SetBewerbsTyp(val typ: String) : BewerbIntent
data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbIntent
}
interface BewerbRepository {
suspend fun listByTurnier(turnierId: Long): List<BewerbListItem>
}
class BewerbViewModel(
private val repo: BewerbRepository,
private val turnierId: Long,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(BewerbState(isLoading = true))
val state: StateFlow<BewerbState> = _state
// Interne Instanz des DialogVM (teilen State via Kopie)
private val dialogVm = BewerbAnlegenViewModel()
init {
send(BewerbIntent.Load)
}
fun send(intent: BewerbIntent) {
when (intent) {
is BewerbIntent.Load, is BewerbIntent.Refresh -> load()
is BewerbIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is BewerbIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) }
is BewerbIntent.OpenDialog -> {
dialogVm.send(BewerbAnlegenIntent.Open)
syncDialogState()
}
is BewerbIntent.CloseDialog -> {
dialogVm.send(BewerbAnlegenIntent.Close)
syncDialogState()
}
is BewerbIntent.SetBewerbsTyp -> {
dialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(intent.typ))
syncDialogState()
}
is BewerbIntent.SetAbteilungsTyp -> {
dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ))
syncDialogState()
}
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.listByTurnier(turnierId)
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<BewerbListItem>, query: String): List<BewerbListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.sparte.contains(q, ignoreCase = true) ||
it.klasse.contains(q, ignoreCase = true) ||
it.tag.contains(q, ignoreCase = true)
}
}
private fun syncDialogState() {
_state.value = _state.value.copy(dialogState = dialogVm.state.value)
}
private inline fun reduce(block: (BewerbState) -> BewerbState) {
_state.value = block(_state.value)
}
}
@@ -0,0 +1,98 @@
package at.mocode.turnier.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
// UI-optimiertes List Item für Turniere (unabhängig vom Domänenmodell)
data class TurnierListItem(
val id: Long,
val name: String,
val ort: String,
val startDatum: String,
val endDatum: String,
val status: String,
)
data class TurnierState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<TurnierListItem> = emptyList(),
val filtered: List<TurnierListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface TurnierIntent {
data object Load : TurnierIntent
data object Refresh : TurnierIntent
data class SearchChanged(val query: String) : TurnierIntent
data class Select(val id: Long?) : TurnierIntent
data object ClearError : TurnierIntent
}
interface TurnierRepository {
suspend fun list(): List<TurnierListItem>
// Platzhalter für B-2: suspend fun get(id: Long): TurnierDetail
}
class TurnierViewModel(
private val repo: TurnierRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(TurnierState(isLoading = true))
val state: StateFlow<TurnierState> = _state
init {
send(TurnierIntent.Load)
}
fun send(intent: TurnierIntent) {
when (intent) {
is TurnierIntent.Load -> load()
is TurnierIntent.Refresh -> load()
is TurnierIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is TurnierIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is TurnierIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
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<TurnierListItem>, query: String): List<TurnierListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.ort.contains(q, ignoreCase = true) ||
it.status.contains(q, ignoreCase = true)
}
}
private inline fun reduce(block: (TurnierState) -> TurnierState) {
_state.value = block(_state.value)
}
}