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