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:
Stefan Mogeritsch 2026-04-03 00:21:09 +02:00
parent 48ffadaaa2
commit 2b3e2d8c1b
8 changed files with 748 additions and 8 deletions

View File

@ -51,14 +51,14 @@
## 🟠 Sprint B — Kurzfristig (nächste Woche)
- [ ] **B-1** | ViewModels für alle V3-Screens umsetzen
- [ ] `TurnierViewModel`
- [ ] `BewerbViewModel` (inkl. Abteilungs-Logik)
- [ ] `PferdProfilViewModel`
- [ ] `ReiterProfilViewModel`
- [ ] `VereinsViewModel`
- [ ] `FunktionaerViewModel`
- [ ] `AbteilungViewModel` (Startliste, Ergebnisse)
- [x] **B-1** | ViewModels für alle V3-Screens umsetzen
- [x] `TurnierViewModel`
- [x] `BewerbViewModel` (inkl. Abteilungs-Logik via Dialog-VM)
- [x] `PferdProfilViewModel`
- [x] `ReiterProfilViewModel`
- [x] `VereinsViewModel`
- [x] `FunktionaerViewModel`
- [x] `AbteilungViewModel` (Startliste, Ergebnisse)
- [ ] **B-2** | Ktor-Clients und Repositories für Backend-Anbindung vorbereiten
- [ ] Ktor-HTTP-Client konfigurieren (BaseURL, Auth-Header, Timeout)

View File

@ -0,0 +1,91 @@
package at.mocode.frontend.features.funktionaer.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 FunktionaerListItem(
val id: Long,
val name: String,
val rolle: String,
val lizenz: String?,
)
data class FunktionaerState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<FunktionaerListItem> = emptyList(),
val filtered: List<FunktionaerListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface FunktionaerIntent {
data object Load : FunktionaerIntent
data object Refresh : FunktionaerIntent
data class SearchChanged(val query: String) : FunktionaerIntent
data class Select(val id: Long?) : FunktionaerIntent
data object ClearError : FunktionaerIntent
}
interface FunktionaerRepository {
suspend fun list(): List<FunktionaerListItem>
}
class FunktionaerViewModel(
private val repo: FunktionaerRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(FunktionaerState(isLoading = true))
val state: StateFlow<FunktionaerState> = _state
init { send(FunktionaerIntent.Load) }
fun send(intent: FunktionaerIntent) {
when (intent) {
is FunktionaerIntent.Load, is FunktionaerIntent.Refresh -> load()
is FunktionaerIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is FunktionaerIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is FunktionaerIntent.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 ?: "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<FunktionaerListItem>, query: String): List<FunktionaerListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.rolle.contains(q, ignoreCase = true) ||
(it.lizenz?.contains(q, ignoreCase = true) ?: false)
}
}
private inline fun reduce(block: (FunktionaerState) -> FunktionaerState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,99 @@
package at.mocode.frontend.features.pferde.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 PferdProfilState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val name: String = "",
val feiId: String = "",
val oepsNummer: String = "",
val geburtsjahr: String = "",
val farbe: String = "",
val rasse: String = "",
val validHints: List<String> = emptyList(),
val dirty: Boolean = false,
)
sealed interface PferdProfilIntent {
data class Load(val id: String) : PferdProfilIntent
data object Refresh : PferdProfilIntent
data class EditName(val v: String) : PferdProfilIntent
data class EditFeiId(val v: String) : PferdProfilIntent
data class EditOeps(val v: String) : PferdProfilIntent
data class EditGeburtsjahr(val v: String) : PferdProfilIntent
data class EditFarbe(val v: String) : PferdProfilIntent
data class EditRasse(val v: String) : PferdProfilIntent
data object Save : PferdProfilIntent
data object ClearError : PferdProfilIntent
}
interface PferdProfilRepository {
suspend fun load(id: String): PferdProfilState
suspend fun save(id: String, state: PferdProfilState)
}
class PferdProfilViewModel(
private val repo: PferdProfilRepository,
private var id: String,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(PferdProfilState(isLoading = true))
val state: StateFlow<PferdProfilState> = _state
init { send(PferdProfilIntent.Load(id)) }
fun send(intent: PferdProfilIntent) {
when (intent) {
is PferdProfilIntent.Load -> { id = intent.id; load() }
is PferdProfilIntent.Refresh -> load()
is PferdProfilIntent.EditName -> edit { it.copy(name = intent.v) }
is PferdProfilIntent.EditFeiId -> edit { it.copy(feiId = intent.v) }
is PferdProfilIntent.EditOeps -> edit { it.copy(oepsNummer = intent.v) }
is PferdProfilIntent.EditGeburtsjahr -> edit { it.copy(geburtsjahr = intent.v) }
is PferdProfilIntent.EditFarbe -> edit { it.copy(farbe = intent.v) }
is PferdProfilIntent.EditRasse -> edit { it.copy(rasse = intent.v) }
is PferdProfilIntent.Save -> save()
is PferdProfilIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val loaded = repo.load(id)
reduce { loaded.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun save() {
val cur = _state.value
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
repo.save(id, cur)
reduce { it.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private inline fun edit(block: (PferdProfilState) -> PferdProfilState) {
reduce { block(it).copy(dirty = true) }
}
private inline fun reduce(block: (PferdProfilState) -> PferdProfilState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,99 @@
package at.mocode.frontend.features.reiter.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 ReiterProfilState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val vorname: String = "",
val nachname: String = "",
val oepsNummer: String = "",
val feiId: String = "",
val lizenzKlasse: String = "",
val verein: String = "",
val validHints: List<String> = emptyList(),
val dirty: Boolean = false,
)
sealed interface ReiterProfilIntent {
data class Load(val id: String) : ReiterProfilIntent
data object Refresh : ReiterProfilIntent
data class EditVorname(val v: String) : ReiterProfilIntent
data class EditNachname(val v: String) : ReiterProfilIntent
data class EditOeps(val v: String) : ReiterProfilIntent
data class EditFeiId(val v: String) : ReiterProfilIntent
data class EditLizenz(val v: String) : ReiterProfilIntent
data class EditVerein(val v: String) : ReiterProfilIntent
data object Save : ReiterProfilIntent
data object ClearError : ReiterProfilIntent
}
interface ReiterProfilRepository {
suspend fun load(id: String): ReiterProfilState
suspend fun save(id: String, state: ReiterProfilState)
}
class ReiterProfilViewModel(
private val repo: ReiterProfilRepository,
private var id: String,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(ReiterProfilState(isLoading = true))
val state: StateFlow<ReiterProfilState> = _state
init { send(ReiterProfilIntent.Load(id)) }
fun send(intent: ReiterProfilIntent) {
when (intent) {
is ReiterProfilIntent.Load -> { id = intent.id; load() }
is ReiterProfilIntent.Refresh -> load()
is ReiterProfilIntent.EditVorname -> edit { it.copy(vorname = intent.v) }
is ReiterProfilIntent.EditNachname -> edit { it.copy(nachname = intent.v) }
is ReiterProfilIntent.EditOeps -> edit { it.copy(oepsNummer = intent.v) }
is ReiterProfilIntent.EditFeiId -> edit { it.copy(feiId = intent.v) }
is ReiterProfilIntent.EditLizenz -> edit { it.copy(lizenzKlasse = intent.v) }
is ReiterProfilIntent.EditVerein -> edit { it.copy(verein = intent.v) }
is ReiterProfilIntent.Save -> save()
is ReiterProfilIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val loaded = repo.load(id)
reduce { loaded.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun save() {
val cur = _state.value
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
repo.save(id, cur)
reduce { it.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private inline fun edit(block: (ReiterProfilState) -> ReiterProfilState) {
reduce { block(it).copy(dirty = true) }
}
private inline fun reduce(block: (ReiterProfilState) -> ReiterProfilState) {
_state.value = block(_state.value)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,91 @@
package at.mocode.frontend.features.verein.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 VereinsListItem(
val id: Long,
val name: String,
val ort: String,
val oepsNummer: String,
)
data class VereinsState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<VereinsListItem> = emptyList(),
val filtered: List<VereinsListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface VereinsIntent {
data object Load : VereinsIntent
data object Refresh : VereinsIntent
data class SearchChanged(val query: String) : VereinsIntent
data class Select(val id: Long?) : VereinsIntent
data object ClearError : VereinsIntent
}
interface VereinsRepository {
suspend fun list(): List<VereinsListItem>
}
class VereinsViewModel(
private val repo: VereinsRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(VereinsState(isLoading = true))
val state: StateFlow<VereinsState> = _state
init { send(VereinsIntent.Load) }
fun send(intent: VereinsIntent) {
when (intent) {
is VereinsIntent.Load, is VereinsIntent.Refresh -> load()
is VereinsIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is VereinsIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is VereinsIntent.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 ?: "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<VereinsListItem>, query: String): List<VereinsListItem> {
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.oepsNummer.contains(q, ignoreCase = true)
}
}
private inline fun reduce(block: (VereinsState) -> VereinsState) {
_state.value = block(_state.value)
}
}