From 2b3e2d8c1b2de0678d323d11fe65c6dfc8faba73 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Fri, 3 Apr 2026 00:21:09 +0200 Subject: [PATCH] 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. --- docs/04_Agents/Roadmaps/Frontend_Roadmap.md | 16 +-- .../presentation/FunktionaerViewModel.kt | 91 ++++++++++++ .../presentation/PferdProfilViewModel.kt | 99 +++++++++++++ .../presentation/ReiterProfilViewModel.kt | 99 +++++++++++++ .../presentation/AbteilungViewModel.kt | 132 ++++++++++++++++++ .../feature/presentation/BewerbViewModel.kt | 130 +++++++++++++++++ .../feature/presentation/TurnierViewModel.kt | 98 +++++++++++++ .../verein/presentation/VereinsViewModel.kt | 91 ++++++++++++ 8 files changed, 748 insertions(+), 8 deletions(-) create mode 100644 frontend/features/funktionaer-feature/src/commonMain/kotlin/at/mocode/frontend/features/funktionaer/presentation/FunktionaerViewModel.kt create mode 100644 frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt create mode 100644 frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt create mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt create mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt create mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt create mode 100644 frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinsViewModel.kt diff --git a/docs/04_Agents/Roadmaps/Frontend_Roadmap.md b/docs/04_Agents/Roadmaps/Frontend_Roadmap.md index 03e748f8..a0452454 100644 --- a/docs/04_Agents/Roadmaps/Frontend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Frontend_Roadmap.md @@ -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) diff --git a/frontend/features/funktionaer-feature/src/commonMain/kotlin/at/mocode/frontend/features/funktionaer/presentation/FunktionaerViewModel.kt b/frontend/features/funktionaer-feature/src/commonMain/kotlin/at/mocode/frontend/features/funktionaer/presentation/FunktionaerViewModel.kt new file mode 100644 index 00000000..153d3e06 --- /dev/null +++ b/frontend/features/funktionaer-feature/src/commonMain/kotlin/at/mocode/frontend/features/funktionaer/presentation/FunktionaerViewModel.kt @@ -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 = emptyList(), + val filtered: List = 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 +} + +class FunktionaerViewModel( + private val repo: FunktionaerRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(FunktionaerState(isLoading = true)) + val state: StateFlow = _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, query: String): List { + 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) + } +} diff --git a/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt b/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt new file mode 100644 index 00000000..5636d4e4 --- /dev/null +++ b/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt @@ -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 = 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 = _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) + } +} diff --git a/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt b/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt new file mode 100644 index 00000000..0b5acf0b --- /dev/null +++ b/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt @@ -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 = 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 = _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) + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt new file mode 100644 index 00000000..1cb36c63 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt @@ -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 = emptyList(), + val ergebnisse: List = 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 + suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List + suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?) + suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List) + 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 = _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) + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt new file mode 100644 index 00000000..c3bc0eed --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt @@ -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 = emptyList(), + val filtered: List = 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 +} + +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 = _state + + // Interne Instanz des Dialog‑VM (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, query: String): List { + 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) + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt new file mode 100644 index 00000000..7e6b1222 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt @@ -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 = emptyList(), + val filtered: List = 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 + // 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 = _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, query: String): List { + 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) + } +} diff --git a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinsViewModel.kt b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinsViewModel.kt new file mode 100644 index 00000000..7e8337e1 --- /dev/null +++ b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinsViewModel.kt @@ -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 = emptyList(), + val filtered: List = 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 +} + +class VereinsViewModel( + private val repo: VereinsRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(VereinsState(isLoading = true)) + val state: StateFlow = _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, query: String): List { + 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) + } +}