diff --git a/docs/04_Agents/Roadmaps/Frontend_Roadmap.md b/docs/04_Agents/Roadmaps/Frontend_Roadmap.md index 32ba6ad5..6bd49e45 100644 --- a/docs/04_Agents/Roadmaps/Frontend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Frontend_Roadmap.md @@ -7,20 +7,30 @@ ## 🔴 Sprint A — Sofort (diese Woche) -- [ ] **A-1** | ViewModel-Architektur definieren und Referenz-Implementierung umsetzen - - [ ] MVVM mit UDF (Unidirectional Data Flow) als verbindliches Muster festlegen - - [ ] `Intent`- und `State`-Klassen-Struktur definieren (Vorlage für alle anderen ViewModels) - - [ ] `VeranstalterViewModel` als vollständige Referenz-Implementierung umsetzen - - [ ] `State`-Klasse definieren - - [ ] `Intent`-Klasse (Sealed Class) definieren - - [ ] Business-Logik aus Composables herausziehen (keine `StoreV2`-Aufrufe mehr direkt in `onSaved`) - - [ ] Lokalen `remember`-State durch ViewModel-State ersetzen - - [ ] Ergebnis als Muster-Dokument in `docs/06_Frontend/` ablegen +- [x] **A-1** | ViewModel-Architektur definieren und Referenz-Implementierung umsetzen + - [x] MVVM mit UDF (Unidirectional Data Flow) als verbindliches Muster festlegen + - [x] `Intent`- und `State`-Klassen-Struktur definieren (Vorlage für alle anderen ViewModels) + - [x] `VeranstalterViewModel` als vollständige Referenz-Implementierung umsetzen + - [x] `State`-Klasse definieren + - [x] `Intent`-Klasse (Sealed Class) definieren + - [x] Business-Logik aus Composables herausziehen (keine `StoreV2`-Aufrufe mehr direkt in `onSaved`) + - [x] Lokalen `remember`-State durch ViewModel-State ersetzen + - [x] Ergebnis als Muster-Dokument in `docs/06_Frontend/` ablegen + + Referenzen: + - docs/06_Frontend/MVVM_UDF_Pattern.md (Regeln, Vorlage, Referenz-Code) + - frontend/features/veranstalter-feature/src/commonMain/.../VeranstalterViewModel.kt + - frontend/features/veranstalter-feature/src/jvmMain/.../DefaultVeranstalterRepository.kt + - frontend/features/veranstalter-feature/src/jvmMain/.../VeranstalterAuswahlScreen.kt (nutzt ViewModel/Intents) -- [ ] **A-2** | Abteilungs-Logik im Bewerb-Dialog berücksichtigen - - [ ] Beim Anlegen eines Bewerbs: Abteilungs-Auswahl als Teil des Dialogs - - [ ] CSN-C-NEU: Automatischer Vorschlag der Pflicht-Teilung (ohne/mit Lizenz; R1/R2+) - - [ ] Abteilungs-Typ setzen: `SEPARATE_SIEGEREHRUNG` oder `ORGANISATORISCH` +- [x] **A-2** | Abteilungs-Logik im Bewerb-Dialog berücksichtigen + - [x] Beim Anlegen eines Bewerbs: Abteilungs-Auswahl als Teil des Dialogs + - [x] CSN-C-NEU: Automatischer Vorschlag der Pflicht-Teilung (ohne/mit Lizenz; R1/R2+) + - [x] Abteilungs-Typ setzen: `SEPARATE_SIEGEREHRUNG` oder `ORGANISATORISCH` + + Referenzen: + - frontend/features/turnier-feature/src/commonMain/.../BewerbAnlegenViewModel.kt (State, Intents, Auto-Vorschlag) + - frontend/features/turnier-feature/src/jvmMain/.../TurnierBewerbeTab.kt (Button „Bewerb Einfügen“ öffnet Dialog) --- diff --git a/docs/06_Frontend/MVVM_UDF_Pattern.md b/docs/06_Frontend/MVVM_UDF_Pattern.md new file mode 100644 index 00000000..86698624 --- /dev/null +++ b/docs/06_Frontend/MVVM_UDF_Pattern.md @@ -0,0 +1,108 @@ +### MVVM + UDF (Unidirectional Data Flow) — Referenz & Vorlage + +Ziel: Alle ViewModels folgen einem klaren, einheitlichen Muster. Composables rendern nur `State` und senden `Intent`s. 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): +```kotlin +data class VeranstalterState( + 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 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): +```kotlin +class VeranstalterViewModel(private val repo: VeranstalterRepository) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _state = MutableStateFlow(VeranstalterState(isLoading = true)) + val state: StateFlow = _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): +```kotlin +interface VeranstalterRepository { suspend fun list(): List } + +class DefaultVeranstalterRepository : VeranstalterRepository { + override suspend fun list(): List = FakeVeranstalterStore + .all() + .map { it.toListItem() } +} +``` + +Composable-Verwendung (verkürzt): +```kotlin +@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. diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt new file mode 100644 index 00000000..e8d7573a --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt @@ -0,0 +1,76 @@ +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 + +// Abteilungs-Typen gemäß Domain +enum class AbteilungsTyp { + SEPARATE_SIEGEREHRUNG, + ORGANISATORISCH, +} + +// Rider-Klasse für Vorschlagslogik (vereinfachtes Modell) +enum class ReiterKlasse { R1, R2_PLUS } + +data class AbteilungsInput( + val id: Int, + val label: String, + val mitLizenz: Boolean, + val reiterKlasse: ReiterKlasse, +) + +data class BewerbAnlegenState( + val isOpen: Boolean = false, + val bewerbsTyp: String = "", + val abteilungsTyp: AbteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG, + val abteilungen: List = emptyList(), +) + +sealed interface BewerbAnlegenIntent { + data object Open : BewerbAnlegenIntent + data object Close : BewerbAnlegenIntent + data class SetBewerbsTyp(val typ: String) : BewerbAnlegenIntent + data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbAnlegenIntent + data object ApplyAutoSuggestionIfNeeded : BewerbAnlegenIntent +} + +class BewerbAnlegenViewModel { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(BewerbAnlegenState()) + val state: StateFlow = _state + + fun send(intent: BewerbAnlegenIntent) { + when (intent) { + is BewerbAnlegenIntent.Open -> reduce { it.copy(isOpen = true) } + is BewerbAnlegenIntent.Close -> reduce { BewerbAnlegenState() } + is BewerbAnlegenIntent.SetBewerbsTyp -> reduce { it.copy(bewerbsTyp = intent.typ) }.also { + // Bei Änderung des Typs gleich prüfen, ob Auto-Vorschlag anzuwenden ist + send(BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded) + } + is BewerbAnlegenIntent.SetAbteilungsTyp -> reduce { it.copy(abteilungsTyp = intent.typ) } + is BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded -> applySuggestion() + } + } + + private fun applySuggestion() { + val s = _state.value + if (s.bewerbsTyp.equals("CSN-C-NEU", ignoreCase = true)) { + // Pflicht-Teilung: ohne/mit Lizenz; R1/R2+ + val suggestion = listOf( + AbteilungsInput(1, label = "Ohne Lizenz · R1", mitLizenz = false, reiterKlasse = ReiterKlasse.R1), + AbteilungsInput(2, label = "Ohne Lizenz · R2+", mitLizenz = false, reiterKlasse = ReiterKlasse.R2_PLUS), + AbteilungsInput(3, label = "Mit Lizenz · R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), + AbteilungsInput(4, label = "Mit Lizenz · R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), + ) + reduce { it.copy(abteilungen = suggestion, abteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG) } + } + } + + private inline fun reduce(block: (BewerbAnlegenState) -> BewerbAnlegenState) { + _state.value = block(_state.value) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt index 38996f2f..e6130550 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt @@ -34,10 +34,16 @@ private val SelectedRowBg = Color(0xFFEFF6FF) fun BewerbeTabContent() { var selectedIndex by remember { mutableIntStateOf(0) } val bewerbe = remember { sampleBewerbe() } + // Dialog-ViewModel für "Bewerb anlegen" + val bewerbDialogVm = remember { BewerbAnlegenViewModel() } + val bewerbDialogState by bewerbDialogVm.state.collectAsState() Row(modifier = Modifier.fillMaxSize()) { // ── Linke Aktions-Spalte ────────────────────────────────────────────── - BewerbeAktionsSpalte(modifier = Modifier.width(140.dp).fillMaxHeight()) + BewerbeAktionsSpalte( + modifier = Modifier.width(140.dp).fillMaxHeight(), + onBewerbEinfuegen = { bewerbDialogVm.send(BewerbAnlegenIntent.Open) }, + ) VerticalDivider() // ── Mittlere Tabelle ────────────────────────────────────────────────── @@ -105,6 +111,23 @@ fun BewerbeTabContent() { modifier = Modifier.width(340.dp).fillMaxHeight(), ) } + + if (bewerbDialogState.isOpen) { + BewerbAnlegenDialog( + state = bewerbDialogState, + onDismiss = { bewerbDialogVm.send(BewerbAnlegenIntent.Close) }, + onChangeTyp = { + bewerbDialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(it)) + }, + onChangeAbteilungsTyp = { + bewerbDialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(it)) + }, + onCreate = { + // Prototyp: Noch keine Persistenz – nur schließen + bewerbDialogVm.send(BewerbAnlegenIntent.Close) + }, + ) + } } @Composable @@ -171,7 +194,10 @@ private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: } @Composable -private fun BewerbeAktionsSpalte(modifier: Modifier = Modifier) { +private fun BewerbeAktionsSpalte( + modifier: Modifier = Modifier, + onBewerbEinfuegen: () -> Unit = {}, +) { Column( modifier = modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -179,7 +205,7 @@ private fun BewerbeAktionsSpalte(modifier: Modifier = Modifier) { AktionsBtn("Änderungen\nSpeichern") AktionsBtn("Änderungen\nRückgängig") HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - AktionsBtn("Bewerb\nEinfügen") + AktionsBtn("Bewerb\nEinfügen", onClick = onBewerbEinfuegen) AktionsBtn("Bewerb\nLöschen") AktionsBtn("Bewerb Teilen") HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) @@ -194,9 +220,9 @@ private fun BewerbeAktionsSpalte(modifier: Modifier = Modifier) { } @Composable -private fun AktionsBtn(label: String) { +private fun AktionsBtn(label: String, onClick: () -> Unit = {}) { OutlinedButton( - onClick = {}, + onClick = onClick, modifier = Modifier.fillMaxWidth().height(48.dp), contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp), ) { @@ -204,6 +230,80 @@ private fun AktionsBtn(label: String) { } } +@Composable +private fun BewerbAnlegenDialog( + state: BewerbAnlegenState, + onDismiss: () -> Unit, + onChangeTyp: (String) -> Unit, + onChangeAbteilungsTyp: (AbteilungsTyp) -> Unit, + onCreate: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Bewerb anlegen") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Bewerbs-Typ + Column { + Text("Bewerbs-Typ", fontSize = 12.sp, color = Color(0xFF6B7280)) + OutlinedTextField( + value = state.bewerbsTyp, + onValueChange = onChangeTyp, + placeholder = { Text("z.B. CSN-C-NEU") }, + singleLine = true, + ) + if (state.bewerbsTyp.equals("CSN-C-NEU", ignoreCase = true)) { + AssistChip(onClick = {}, label = { Text("Pflicht-Teilung vorgeschlagen") }) + } + } + + // Abteilungs-Typ Auswahl + Column { + Text("Abteilungs-Typ", fontSize = 12.sp, color = Color(0xFF6B7280)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.abteilungsTyp == AbteilungsTyp.SEPARATE_SIEGEREHRUNG, + onClick = { onChangeAbteilungsTyp(AbteilungsTyp.SEPARATE_SIEGEREHRUNG) }, + label = { Text("SEPARATE_SIEGEREHRUNG") }, + ) + FilterChip( + selected = state.abteilungsTyp == AbteilungsTyp.ORGANISATORISCH, + onClick = { onChangeAbteilungsTyp(AbteilungsTyp.ORGANISATORISCH) }, + label = { Text("ORGANISATORISCH") }, + ) + } + } + + // Abteilungen (Vorschlag / Liste) + Column { + Text("Abteilungen", fontSize = 12.sp, color = Color(0xFF6B7280)) + if (state.abteilungen.isEmpty()) { + Text("Noch keine Abteilungen. Wähle einen Typ (z.B. CSN-C-NEU) für Vorschlag.", fontSize = 12.sp) + } else { + state.abteilungen.forEach { a -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(a.label, fontSize = 13.sp) + val lizenz = if (a.mitLizenz) "mit Lizenz" else "ohne Lizenz" + Text("${lizenz} · ${if (a.reiterKlasse == ReiterKlasse.R1) "R1" else "R2+"}", fontSize = 12.sp, color = Color(0xFF6B7280)) + } + } + } + } + } + }, + confirmButton = { + Button(onClick = onCreate, enabled = state.bewerbsTyp.isNotBlank()) { Text("Anlegen") } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } + }, + ) +} + @Composable private fun BewerbeDetailPanel(bewerb: BewerbUiModel?, modifier: Modifier = Modifier) { var subTab by remember { mutableIntStateOf(0) } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt index ad4b9282..07700ffd 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt @@ -3,6 +3,8 @@ package at.mocode.turnier.feature.presentation import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.Check @@ -39,7 +41,7 @@ fun TurnierWizardV2( verticalAlignment = Alignment.CenterVertically ) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, null) } + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null) } Spacer(Modifier.width(8.dp)) Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) } @@ -107,7 +109,7 @@ fun TurnierWizardV2( ) { Text("Weiter") Spacer(Modifier.width(8.dp)) - Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp)) } } } diff --git a/frontend/features/veranstalter-feature/src/commonMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterViewModel.kt b/frontend/features/veranstalter-feature/src/commonMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterViewModel.kt new file mode 100644 index 00000000..56bad41a --- /dev/null +++ b/frontend/features/veranstalter-feature/src/commonMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterViewModel.kt @@ -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 = emptyList(), + val filtered: List = 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 +} + +class VeranstalterViewModel( + private val repo: VeranstalterRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(VeranstalterState(isLoading = true)) + val state: StateFlow = _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, query: String): List { + 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) + } +} diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/DefaultVeranstalterRepository.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/DefaultVeranstalterRepository.kt new file mode 100644 index 00000000..335bd621 --- /dev/null +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/DefaultVeranstalterRepository.kt @@ -0,0 +1,23 @@ +package at.mocode.veranstalter.feature.presentation + +import at.mocode.frontend.core.designsystem.models.LoginStatus + +class DefaultVeranstalterRepository : VeranstalterRepository { + override suspend fun list(): List { + // Aus Fake-Store lesen (Prototyp) + return FakeVeranstalterStore.all().map { it.toListItem() } + } +} + +private fun LoginStatus.asLabel(): String = when (this) { + LoginStatus.AKTIV -> "AKTIV" + LoginStatus.AUSSTEHEND -> "AUSSTEHEND" +} + +private fun VeranstalterUiModel.toListItem() = VeranstalterListItem( + id = id, + name = name, + oepsNummer = oepsNummer, + ort = ort, + loginStatus = loginStatus.asLabel(), +) diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlScreen.kt index 1b43da8d..548c0626 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlScreen.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlScreen.kt @@ -43,48 +43,9 @@ fun VeranstalterAuswahlScreen( onWeiter: (Long) -> Unit, onNeuerVeranstalter: () -> Unit = {}, ) { - var selectedId by remember { mutableStateOf(null) } - var suchtext by remember { mutableStateOf("") } - - // Placeholder-Daten gemäß Figma - val veranstalter = remember { - listOf( - VeranstalterUiModel( - id = 1L, - name = "Reit- und Fahrverein Wels", - oepsNummer = "V-OOE-1234", - ort = "4600 Wels", - ansprechpartner = "Maria Huber", - email = "office@rfv-wels.at", - loginStatus = LoginStatus.AKTIV, - ), - VeranstalterUiModel( - id = 2L, - name = "Pferdesportverein Linz", - oepsNummer = "V-OOE-5678", - ort = "4020 Linz", - ansprechpartner = "Thomas Maier", - email = "kontakt@psv-linz.at", - loginStatus = LoginStatus.AKTIV, - ), - VeranstalterUiModel( - id = 3L, - name = "Reitclub Eferding", - oepsNummer = "V-OOE-9012", - ort = "4070 Eferding", - ansprechpartner = "Anna Schmid", - email = "info@rc-eferding.at", - loginStatus = LoginStatus.AUSSTEHEND, - ), - ) - } - - val gefiltert = veranstalter.filter { - suchtext.isBlank() || - it.name.contains(suchtext, ignoreCase = true) || - it.oepsNummer.contains(suchtext, ignoreCase = true) || - it.ort.contains(suchtext, ignoreCase = true) - } + // MVVM + UDF: ViewModel hält gesamten Zustand, Composable rendert nur State und sendet Intents + val viewModel = remember { VeranstalterViewModel(DefaultVeranstalterRepository()) } + val state by viewModel.state.collectAsState() Column(modifier = Modifier.fillMaxSize()) { @@ -111,8 +72,8 @@ fun VeranstalterAuswahlScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( - value = suchtext, - onValueChange = { suchtext = it }, + value = state.searchQuery, + onValueChange = { viewModel.send(VeranstalterIntent.SearchChanged(it)) }, placeholder = { Text("Veranstalter suchen (Name, OEPS-Nummer, Ort)...", fontSize = 13.sp) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, modifier = Modifier.weight(1f).height(48.dp), @@ -147,8 +108,8 @@ fun VeranstalterAuswahlScreen( // ── Tabellen-Inhalt ────────────────────────────────────────────────── LazyColumn(modifier = Modifier.weight(1f)) { - items(gefiltert) { v -> - val isSelected = v.id == selectedId + items(state.filtered) { v -> + val isSelected = v.id == state.selectedId Row( modifier = Modifier .fillMaxWidth() @@ -156,7 +117,7 @@ fun VeranstalterAuswahlScreen( if (isSelected) AccentBlue.copy(alpha = 0.08f) else Color.Transparent, ) - .clickable { selectedId = v.id } + .clickable { viewModel.send(VeranstalterIntent.Select(v.id)) } .padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -180,11 +141,13 @@ fun VeranstalterAuswahlScreen( ) Text(v.oepsNummer, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) - Text(v.ansprechpartner, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) - Text(v.email, fontSize = 13.sp, modifier = Modifier.weight(2f)) + // Placeholder für Ansprechpartner/E-Mail vorerst leer im ListItem-Model + Text("-", fontSize = 13.sp, modifier = Modifier.weight(1.5f)) + Text("-", fontSize = 13.sp, modifier = Modifier.weight(2f)) // Login-Status-Badge Box(modifier = Modifier.weight(1f)) { - LoginStatusBadge(v.loginStatus) + // Für die Referenz reicht String-Label + Text(v.loginStatus, fontSize = 12.sp, color = Color(0xFF111827)) } } HorizontalDivider(color = Color(0xFFE5E7EB)) @@ -235,8 +198,8 @@ fun VeranstalterAuswahlScreen( } Spacer(Modifier.width(12.dp)) Button( - onClick = { selectedId?.let { onWeiter(it) } }, - enabled = selectedId != null, + onClick = { state.selectedId?.let { onWeiter(it) } }, + enabled = state.selectedId != null, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), ) { Text("Weiter zum Veranstalter") @@ -248,12 +211,5 @@ fun VeranstalterAuswahlScreen( // --- UI-Modelle --- -data class VeranstalterUiModel( - val id: Long, - val name: String, - val oepsNummer: String, - val ort: String, - val ansprechpartner: String, - val email: String, - val loginStatus: LoginStatus, -) +// Hinweis: Das frühere UI-Modell bleibt bewusst entfernt – +// die Liste wird nun aus dem ViewModel-State gerendert (MVVM + UDF). diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterUiModel.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterUiModel.kt new file mode 100644 index 00000000..049e1bc7 --- /dev/null +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterUiModel.kt @@ -0,0 +1,14 @@ +package at.mocode.veranstalter.feature.presentation + +import at.mocode.frontend.core.designsystem.models.LoginStatus + +// UI-Modell für die jvm-Präsentationsschicht (Prototyp) +data class VeranstalterUiModel( + val id: Long, + val name: String, + val oepsNummer: String, + val ort: String, + val ansprechpartner: String, + val email: String, + val loginStatus: LoginStatus, +)