chore: refactor Veranstalter screens and ViewModels to decouple from Koin, implement hoisting for better testability, consolidate legacy shell logic into modern components, and add mock repositories for previews

This commit is contained in:
2026-04-20 10:31:41 +02:00
parent 2489beab59
commit 8aef46bba1
7 changed files with 245 additions and 274 deletions
@@ -1,22 +1,14 @@
package at.mocode.frontend.shell.desktop.screens.management
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.theme.DesktopTheme
import androidx.compose.runtime.Composable
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
import org.koin.compose.koinInject
/**
* Delegates für die Veranstalter-Screens in der Desktop-Shell.
* Diese binden die Plug-and-Play Komponenten aus dem veranstalter-feature ein.
*/
@Composable
fun VeranstalterAuswahl(
@@ -24,55 +16,12 @@ fun VeranstalterAuswahl(
onWeiter: (Long) -> Unit,
onNeu: () -> Unit,
) {
DesktopTheme {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
modifier = Modifier.clickable { onBack() })
Text("Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onNeu) { Text("+ Neuer Veranstalter") }
}
var selectedId by remember { mutableStateOf<Long?>(null) }
LazyColumn(Modifier.fillMaxSize().weight(1f)) {
items(Store.vereine) { v ->
val sel = selectedId == v.id
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable { selectedId = v.id },
colors = if (sel) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
else CardDefaults.cardColors()
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(40.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White)
}
Spacer(Modifier.width(12.dp))
Column {
Text(v.name, style = MaterialTheme.typography.titleMedium)
Text("OEPS: ${v.oepsNummer} · ${v.ort ?: ""}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
Button(
onClick = { selectedId?.let(onWeiter) },
enabled = selectedId != null,
modifier = Modifier.fillMaxWidth()
) { Text("Weiter zum Veranstalter") }
}
}
VeranstalterAuswahlScreen(
viewModel = koinInject(),
onZurueck = onBack,
onWeiter = onWeiter,
onNeuerVeranstalter = onNeu
)
}
@Composable
@@ -82,127 +31,11 @@ fun VeranstalterDetail(
onZurVeranstaltung: (Long) -> Unit,
onNeuVeranstaltung: () -> Unit,
) {
LaunchedEffect(veranstalterId) { println("[Screen] VeranstalterDetail geladen (VstID: $veranstalterId)") }
DesktopTheme {
val verein = remember(veranstalterId) { Store.vereine.firstOrNull { it.id == veranstalterId } }
if (verein == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Veranstalter nicht gefunden")
}
return@DesktopTheme
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
modifier = Modifier.clickable { onBack() })
Text(verein.name, style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") }
}
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text(
(verein.kurzname ?: verein.name).take(2).uppercase(),
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(verein.name, style = MaterialTheme.typography.titleMedium)
val line2 = listOfNotNull(
"OEPS: ${verein.oepsNummer}",
verein.ort,
verein.plz,
verein.strasse
).filter { it.isNotBlank() }.joinToString(" · ")
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
val line3 = listOfNotNull(verein.email, verein.telefon).filter { it.isNotBlank() }.joinToString(" · ")
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
}
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
var name by remember { mutableStateOf(verein.name) }
var oeps by remember { mutableStateOf(verein.oepsNummer) }
var ort by remember { mutableStateOf(verein.ort ?: "") }
var plz by remember { mutableStateOf(verein.plz ?: "") }
var strasse by remember { mutableStateOf(verein.strasse ?: "") }
var email by remember { mutableStateOf(verein.email ?: "") }
var tel by remember { mutableStateOf(verein.telefon ?: "") }
var logo by remember { mutableStateOf(verein.logoUrl ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
verein.name = name
verein.oepsNummer = oeps
verein.ort = ort.ifBlank { null }
verein.plz = plz.ifBlank { null }
verein.strasse = strasse.ifBlank { null }
verein.email = email.ifBlank { null }
verein.telefon = tel.ifBlank { null }
verein.logoUrl = logo.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Veranstalter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(logo, { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(
strasse,
{ strasse = it },
label = { Text("Straße") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
}
}
}
)
}
Text("Veranstaltungen", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
val events = remember(veranstalterId) { Store.eventsFor(veranstalterId) }
if (events.isEmpty()) {
Text("Keine Veranstaltungen angelegt", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(events) { ev ->
ListItem(
headlineContent = { Text(ev.titel) },
supportingContent = { Text("${ev.datumVon} bis ${ev.datumBis ?: "?"} · ${ev.status}") },
trailingContent = { Icon(Icons.Default.ChevronRight, null) },
modifier = Modifier.clickable { onZurVeranstaltung(ev.id) }
)
}
}
}
}
}
VeranstalterDetailScreen(
veranstalterId = veranstalterId,
viewModel = koinInject(),
onZurueck = onBack,
onVeranstaltungOeffnen = onZurVeranstaltung,
onVeranstaltungNeu = onNeuVeranstaltung
)
}
@@ -7,11 +7,11 @@ import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequ
import at.mocode.frontend.features.turnier.domain.*
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
import at.mocode.frontend.features.turnier.presentation.*
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterNeuScreen
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.presentation.*
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// ─────────────────────────────────────────────────────────────────────────────
// Compose Desktop Previews alle wichtigen Screens auf einen Blick
@@ -25,8 +25,20 @@ import at.mocode.zns.parser.ZnsBewerb
@ComponentPreview
@Composable
fun PreviewVeranstalterAuswahlScreen() {
val mockRepo = object : VeranstalterRepository {
override suspend fun list(): Result<List<DomainVeranstalter>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<DomainVeranstalter> = Result.failure(NotImplementedError())
override suspend fun create(model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
}
MaterialTheme {
VeranstalterAuswahlScreen(
viewModel = VeranstalterViewModel(mockRepo),
onZurueck = {},
onWeiter = {},
onNeuerVeranstalter = {},
@@ -52,9 +64,21 @@ fun PreviewVeranstalterNeuScreen() {
@ComponentPreview
@Composable
fun PreviewVeranstalterDetailScreen() {
val mockRepo = object : VeranstalterRepository {
override suspend fun list(): Result<List<DomainVeranstalter>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<DomainVeranstalter> = Result.failure(NotImplementedError())
override suspend fun create(model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
}
MaterialTheme {
VeranstalterDetailScreen(
veranstalterId = 1L,
viewModel = VeranstalterDetailViewModel(mockRepo),
onZurueck = {},
onVeranstaltungOeffnen = {},
onVeranstaltungNeu = {},