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:
+99
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
@@ -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(),
|
||||
)
|
||||
+17
-61
@@ -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).
|
||||
|
||||
+14
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user