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:
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+105
-5
@@ -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) }
|
||||
|
||||
+4
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user