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:
Stefan Mogeritsch 2026-04-02 22:29:16 +02:00
parent a8bc82eb91
commit 5e4c292f0c
9 changed files with 469 additions and 81 deletions

View File

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

View File

@ -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<VeranstalterListItem> = emptyList(),
val filtered: List<VeranstalterListItem> = 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<VeranstalterState> = _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<VeranstalterListItem> }
class DefaultVeranstalterRepository : VeranstalterRepository {
override suspend fun list(): List<VeranstalterListItem> = 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.

View File

@ -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<AbteilungsInput> = 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<BewerbAnlegenState> = _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)
}
}

View File

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

View File

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

View File

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

View File

@ -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<VeranstalterListItem> {
// 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(),
)

View File

@ -43,48 +43,9 @@ fun VeranstalterAuswahlScreen(
onWeiter: (Long) -> Unit,
onNeuerVeranstalter: () -> Unit = {},
) {
var selectedId by remember { mutableStateOf<Long?>(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).

View File

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