feat(verein-feature): add Vereinsverwaltung module with screens, ViewModel, and integration

- Introduced `verein-feature` module for managing Vereine, including list, detail, and editor views using `MsMasterDetailLayout`.
- Added new domain models (`Verein`, `VereinStatus`) and integrated mock data for development.
- Registered the new feature in `settings.gradle.kts` and `DesktopMainLayout.kt`, including breadcrumb navigation and entry point.
- Updated `VeranstaltungenUebersichtV2` to add Vereine as a quick-access KPI tile.
- Removed unnecessary logout functionality and adjusted the root navigation for consistency.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-31 15:00:19 +02:00
parent 1699c24875
commit 496e801943
15 changed files with 1109 additions and 151 deletions
@@ -38,6 +38,7 @@ kotlin {
implementation(project(":frontend:features:reiter-feature"))
implementation(project(":frontend:features:pferde-feature"))
implementation(project(":frontend:features:billing-feature"))
implementation(project(":frontend:features:verein-feature"))
// Compose Desktop
implementation(compose.desktop.currentOs)
@@ -42,7 +42,7 @@ fun DesktopApp() {
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
&& currentScreen !is AppScreen.VeranstaltungUebersicht && currentScreen !is AppScreen.TurnierDetail
&& currentScreen !is AppScreen.TurnierNeu
&& currentScreen !is AppScreen.TurnierNeu && currentScreen !is AppScreen.Vereine
) {
LaunchedEffect(Unit) {
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
@@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.profile.di.profileModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.zns.feature.di.znsImportModule
@@ -35,6 +36,7 @@ fun main() = application {
znsImportModule,
profileModule,
billingModule,
vereinFeatureModule,
desktopModule,
)
}
@@ -5,10 +5,8 @@ import androidx.compose.foundation.clickable
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.Logout
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -20,6 +18,8 @@ import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.features.profile.presentation.ProfileScreen
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel
import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
@@ -31,6 +31,7 @@ import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
private val TopBarColor = Color(0xFF1E3A8A)
@@ -107,7 +108,7 @@ private fun DesktopTopBar(
// Root-Link
Text(
text = "🏠 Admin - Verwaltung",
text = "Veranstaltungen",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
@@ -242,18 +243,21 @@ private fun DesktopTopBar(
fontSize = 14.sp,
)
}
is AppScreen.Vereine -> {
BreadcrumbSeparator()
Text(
text = "Vereine",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
else -> {}
}
}
// Logout rechts
IconButton(onClick = onLogout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Abmelden",
tint = TopBarTextColor,
)
}
// Logout wurde auf Kundenwunsch entfernt
}
}
@@ -302,7 +306,7 @@ private fun DesktopContentArea(
is AppScreen.Veranstaltungen -> {
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
onNewEvent = { onNavigate(AppScreen.VeranstalterAuswahl) }
onNewEvent = { onNavigate(AppScreen.VeranstaltungKonfig()) }
)
}
@@ -335,19 +339,15 @@ private fun DesktopContentArea(
}
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
// V2: Validierung über StoreV2
if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
)
} else {
at.mocode.desktop.v2.VeranstaltungKonfigV2(
veranstalterId = vId,
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }
)
}
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
at.mocode.desktop.v2.VeranstaltungKonfigV2(
veranstalterId = vId,
onBack = {
if (vId == 0L) onNavigate(AppScreen.Veranstaltungen)
else onNavigate(AppScreen.VeranstalterDetail(vId))
},
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungUebersicht(finalVId, evtId)) }
)
}
is AppScreen.VeranstaltungUebersicht -> {
val vId = currentScreen.veranstalterId
@@ -438,6 +438,14 @@ private fun DesktopContentArea(
)
}
// Vereins-Verwaltung
is AppScreen.Vereine -> {
val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(
viewModel = vereinViewModel
)
}
// Fallback → Root
else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
@@ -12,19 +12,39 @@ data class Verein(
data class VeranstaltungV2(
val id: Long,
val veranstalterId: Long,
var veranstalterId: Long,
var titel: String,
var datumVon: String,
var datumBis: String?,
var status: String = "In Vorbereitung",
var beschreibung: String = "",
var untertitel: String = "",
var ort: String = "",
var logoUrl: String? = null,
var sponsoren: SnapshotStateList<String> = mutableStateListOf(),
)
object StoreV2 {
val oepsStammdaten: List<Verein> = listOf(
Verein(1001, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
Verein(1002, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
Verein(1003, "Reitclub Ebelsberg", "V-OOE-0003", "Linz-Ebelsberg"),
Verein(1004, "Union Reitverein Gschwandt", "V-OOE-0004", "Gschwandt"),
Verein(1005, "Reitsportclub Gleisdorf", "V-ST-0005", "Gleisdorf"),
Verein(1006, "Pferdesportzentrum Stadl-Paura", "V-OOE-0006", "Stadl-Paura"),
)
val vereine: SnapshotStateList<Verein> = mutableStateListOf(
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
)
fun addVerein(name: String, oeps: String, ort: String): Long {
val id = (vereine.maxOfOrNull { it.id } ?: 0) + 1
vereine.add(Verein(id, name, oeps, ort))
return id
}
private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf()
fun seed() {
@@ -40,7 +60,8 @@ object StoreV2 {
titel = "Frühjahrsturnier Neumarkt/M. 2026",
datumVon = "2026-04-10",
datumBis = "2026-04-12",
status = "Nennungsphase"
status = "Nennungsphase",
beschreibung = "Traditionelles Frühjahrsturnier mit Spring- und Dressurprüfungen bis Klasse LM."
)
)
@@ -63,7 +84,8 @@ object StoreV2 {
titel = "Linzer Pferdefestival",
datumVon = "2026-05-20",
datumBis = "2026-05-24",
status = "In Vorbereitung"
status = "In Vorbereitung",
beschreibung = "Großes Reit-Event am Ebelsberger Schlosspark."
)
)
TurnierStoreV2.add(linzId, TurnierV2(201, linzId, 26500, "CSN-B*", "2026-05-20", "2026-05-24"))
@@ -7,27 +7,45 @@ 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.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.*
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VeranstaltungenUebersichtV2(
onEventOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewEvent: () -> Unit
) {
DesktopThemeV2 {
val events = remember { StoreV2.allEvents() }
val allEvents = remember { StoreV2.allEvents() }
val vereine = StoreV2.vereine
var searchQuery by remember { mutableStateOf("") }
var selectedStatus by remember { mutableStateOf<String?>(null) }
val availableStatuses = remember(allEvents) { allEvents.map { it.status }.distinct().sorted() }
val filteredEvents = remember(allEvents, searchQuery, selectedStatus) {
allEvents.filter { event ->
val verein = vereine.find { it.id == event.veranstalterId }
val matchesSearch = event.titel.contains(searchQuery, ignoreCase = true) ||
(verein?.name?.contains(searchQuery, ignoreCase = true) ?: false)
val matchesStatus = selectedStatus == null || event.status == selectedStatus
matchesSearch && matchesStatus
}.sortedByDescending { it.datumVon }
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Header
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -41,13 +59,61 @@ fun VeranstaltungenUebersichtV2(
}
}
if (events.isEmpty()) {
// Filter & Suche
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Suche nach Titel oder Verein...") },
modifier = Modifier.weight(1f),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Löschen")
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium
)
// Status Filter Chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.FilterList, contentDescription = null, tint = Color.Gray)
FilterChip(
selected = selectedStatus == null,
onClick = { selectedStatus = null },
label = { Text("Alle") }
)
availableStatuses.forEach { status ->
FilterChip(
selected = selectedStatus == status,
onClick = { selectedStatus = if (selectedStatus == status) null else status },
label = { Text(status) }
)
}
}
}
}
}
if (filteredEvents.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Veranstaltungen gefunden.", color = Color.Gray)
Text(
if (searchQuery.isEmpty() && selectedStatus == null) "Keine Veranstaltungen gefunden."
else "Keine Ergebnisse für deine Suche/Filter.",
color = Color.Gray
)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(events.sortedByDescending { it.datumVon }) { event ->
items(filteredEvents) { event ->
val verein = vereine.find { it.id == event.veranstalterId }
Card(
modifier = Modifier.fillMaxWidth().clickable { onEventOpen(event.veranstalterId, event.id) },
@@ -60,12 +126,27 @@ fun VeranstaltungenUebersichtV2(
"${verein?.name ?: "Unbekannter Verein"} | ${event.datumVon} bis ${event.datumBis ?: ""}",
style = MaterialTheme.typography.bodySmall
)
if (event.beschreibung.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
event.beschreibung,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
color = Color.DarkGray
)
}
}
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
event.status,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Text(
event.status,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
@@ -81,106 +162,496 @@ fun VeranstaltungenUebersichtV2(
}
@Composable
fun VeranstaltungKonfigV2(
veranstalterId: Long,
onBack: () -> Unit,
onSaved: (Long) -> Unit,
fun VeranstalterAnlegenWizard(
onCancel: () -> Unit,
onVereinCreated: (Long) -> Unit,
) {
DesktopThemeV2 {
var currentStep by remember { mutableStateOf(1) }
var geraetName by remember { mutableStateOf("") }
var securityKey by remember { mutableStateOf("") }
var step by remember { mutableStateOf(1) } // 1: Suche in Stammdaten, 2: Details/Bestätigung
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() })
// State für Suche
var searchQuery by remember { mutableStateOf("") }
// State für Details (falls manuell oder ergänzt)
var name by remember { mutableStateOf("") }
var oeps by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.2f)),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (currentStep == 1) "Neue Veranstaltung: Geräteschutz" else "Neue Veranstaltung: Basisdaten",
style = MaterialTheme.typography.titleLarge
if (step == 1) "Schritt 1: Verein in Stammdaten finden" else "Schritt 2: Vereinsdaten bestätigen",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onCancel) {
Icon(Icons.Default.Close, contentDescription = "Abbrechen")
}
}
if (currentStep == 1) {
// --- STEP 1: Device Onboarding ---
Text(
"Bevor du eine Veranstaltung anlegst, musst du dieses Gerät benennen und einen lokalen Sicherheitsschlüssel festlegen.",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
label = { Text("Gerätename (z.B. Meldestelle-1)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = securityKey,
onValueChange = { securityKey = it },
label = { Text("Lokaler Sicherheitsschlüssel") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation()
)
val step1Enabled = geraetName.isNotBlank() && securityKey.length >= 8
Button(onClick = { currentStep = 2 }, enabled = step1Enabled) {
Text("Weiter zu den Veranstaltungsdaten")
}
if (securityKey.isNotEmpty() && securityKey.length < 8) {
Text(
"Der Schlüssel muss mindestens 8 Zeichen lang sein.",
color = Color(0xFFB00020),
style = MaterialTheme.typography.labelSmall
)
}
} else {
// --- STEP 2: Event Data ---
var titel by remember { mutableStateOf("") }
var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel (Pflicht)") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (step == 1) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = von,
onValueChange = { von = it },
label = { Text("von (YYYY-MM-DD)") },
modifier = Modifier.weight(1f)
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Nach Name, Ort oder OEPS-Nr suchen...") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
)
OutlinedTextField(
value = bis,
onValueChange = { bis = it },
label = { Text("bis (YYYY-MM-DD)") },
modifier = Modifier.weight(1f)
)
}
val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von)
if (!validDates && von.isNotEmpty()) Text(
"bis-Datum darf nicht vor von-Datum liegen",
color = Color(0xFFB00020)
)
val enabled = titel.trim().isNotEmpty() && validDates
val results = remember(searchQuery) {
if (searchQuery.length < 2) emptyList()
else StoreV2.oepsStammdaten.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.ort.contains(searchQuery, ignoreCase = true) ||
it.oepsNummer.contains(searchQuery, ignoreCase = true)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { currentStep = 1 }) { Text("Zurück") }
Button(onClick = {
val id = System.currentTimeMillis()
StoreV2.addEventFirst(
veranstalterId,
VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null })
if (results.isNotEmpty()) {
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
items(results) { v ->
ListItem(
headlineContent = { Text(v.name) },
supportingContent = { Text("${v.ort} | ${v.oepsNummer}") },
modifier = Modifier.clickable {
name = v.name
oeps = v.oepsNummer
ort = v.ort
step = 2
}
)
}
}
} else if (searchQuery.length >= 2) {
Text(
"Kein Verein gefunden? Du kannst die Daten auch manuell eingeben.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
onSaved(id)
}, enabled = enabled) { Text("Veranstaltung anlegen") }
OutlinedButton(
onClick = { step = 2 },
modifier = Modifier.fillMaxWidth()
) {
Text("Manuell erfassen")
}
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Vereinsname") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = oeps,
onValueChange = { oeps = it },
label = { Text("OEPS-Nummer") },
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
TextButton(onClick = { step = 1 }) { Text("Zurück zur Suche") }
Button(
onClick = {
val newId = StoreV2.addVerein(name, oeps, ort)
onVereinCreated(newId)
},
enabled = name.isNotBlank() && ort.isNotBlank()
) {
Text("Verein anlegen & weiter")
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VeranstaltungKonfigV2(
veranstalterId: Long = 0,
onBack: () -> Unit,
onSaved: (Long, Long) -> Unit, // eventId, veranstalterId
) {
DesktopThemeV2 {
var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) }
// Step 1: Veranstalterwahl
var selectedVereinId by remember { mutableStateOf(veranstalterId) }
var showVereinNeu by remember { mutableStateOf(false) }
// Step 2: Basisdaten
var titel by remember { mutableStateOf("") }
var untertitel by remember { mutableStateOf("") }
var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) }
// Step 3: Zusatzdaten
var logoUrl by remember { mutableStateOf("") }
var sponsorenText by remember { mutableStateOf("") } // Kommagetrennte Liste
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
fun Long?.toLocalDate(): LocalDate? {
if (this == null) return null
return Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate()
}
if (showDatePickerVon) {
val datePickerState = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerVon = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis.toLocalDate()?.let {
von = it.format(dateFormatter)
}
showDatePickerVon = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePickerVon = false }) { Text("Abbrechen") }
}
) {
DatePicker(state = datePickerState)
}
}
if (showDatePickerBis) {
val datePickerState = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerBis = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis.toLocalDate()?.let {
bis = it.format(dateFormatter)
}
showDatePickerBis = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePickerBis = false }) { Text("Abbrechen") }
}
) {
DatePicker(state = datePickerState)
}
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Header & Navigation
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = {
if (currentStep > 1) {
currentStep--
} else {
onBack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Column {
Text("Neue Veranstaltung anlegen", style = MaterialTheme.typography.headlineSmall)
Text(
when (currentStep) {
1 -> "Schritt 1: Veranstalter auswählen"
2 -> "Schritt 2: Basisdaten der Veranstaltung"
3 -> "Schritt 3: Details & Sponsoren"
else -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
LinearProgressIndicator(
progress = { currentStep / 3f },
modifier = Modifier.fillMaxWidth().height(4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (currentStep) {
1 -> {
// --- SCHRITT 1: Veranstalterwahl ---
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
var search by remember { mutableStateOf("") }
val filteredVereine = remember(search) {
StoreV2.vereine.filter {
it.name.contains(search, ignoreCase = true) || it.ort.contains(search, ignoreCase = true)
}
}
Text("Für welchen Verein wird die Veranstaltung angelegt?", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Veranstalter suchen...") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
)
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredVereine) { verein ->
val isSelected = selectedVereinId == verein.id
Surface(
onClick = { selectedVereinId = verein.id },
shape = MaterialTheme.shapes.medium,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant
)
) {
Row(
Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(verein.name, fontWeight = FontWeight.Bold)
Text("${verein.ort} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall)
}
if (isSelected) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
}
}
HorizontalDivider()
if (!showVereinNeu) {
OutlinedButton(
onClick = { showVereinNeu = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neuen Veranstalter / Verein anlegen")
}
} else {
VeranstalterAnlegenWizard(
onCancel = { showVereinNeu = false },
onVereinCreated = { newId ->
selectedVereinId = newId
showVereinNeu = false
currentStep = 2
}
)
}
}
}
2 -> {
// --- SCHRITT 2: Basisdaten ---
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Allgemeine Informationen", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel der Veranstaltung (z.B. Pfingstturnier 2026)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = untertitel,
onValueChange = { untertitel = it },
label = { Text("Untertitel / Slogan (optional)") },
modifier = Modifier.fillMaxWidth()
)
val dateVon = try {
LocalDate.parse(von, dateFormatter)
} catch (e: Exception) {
null
}
val dateBis = try {
LocalDate.parse(bis, dateFormatter)
} catch (e: Exception) {
null
}
val isDateRangeInvalid = dateVon != null && dateBis != null && dateBis.isBefore(dateVon)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = von,
onValueChange = { /* Schreibgeschützt, via Picker */ },
label = { Text("Datum von") },
modifier = Modifier.weight(1f).clickable { showDatePickerVon = true },
enabled = false,
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
IconButton(onClick = { showDatePickerVon = true }) {
Icon(Icons.Default.DateRange, contentDescription = "Datum wählen")
}
}
)
OutlinedTextField(
value = bis,
onValueChange = { /* Schreibgeschützt, via Picker */ },
label = { Text("Datum bis") },
modifier = Modifier.weight(1f).clickable { showDatePickerBis = true },
enabled = false,
isError = isDateRangeInvalid,
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
disabledLabelColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
),
trailingIcon = {
IconButton(onClick = { showDatePickerBis = true }) {
Icon(Icons.Default.DateRange, contentDescription = "Datum wählen")
}
},
supportingText = {
if (isDateRangeInvalid) {
Text("Enddatum darf nicht vor dem Startdatum liegen.")
}
}
)
}
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Austragungsort (falls abweichend vom Vereinssitz)") },
modifier = Modifier.fillMaxWidth()
)
}
}
3 -> {
// --- SCHRITT 3: Details & Sponsoren ---
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Branding & Partner", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = logoUrl,
onValueChange = { logoUrl = it },
label = { Text("Logo-URL oder Pfad") },
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Optional: Link zu einem Turnierlogo") }
)
OutlinedTextField(
value = sponsorenText,
onValueChange = { sponsorenText = it },
label = { Text("Sponsoren (mit Komma trennen)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
Spacer(Modifier.height(16.dp))
Text(
"Vorschau Sponsoren:",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
sponsorenText.split(",").filter { it.isNotBlank() }.forEach { sponsor ->
SuggestionChip(onClick = {}, label = { Text(sponsor.trim()) })
}
}
}
}
}
}
// Footer Navigation
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (currentStep > 1) {
OutlinedButton(onClick = { currentStep-- }) {
Text("Zurück")
}
} else {
Spacer(Modifier.width(1.dp))
}
Button(
onClick = {
if (currentStep < 3) {
currentStep++
} else {
val id = System.currentTimeMillis()
val v = VeranstaltungV2(
id = id,
veranstalterId = selectedVereinId,
titel = titel.trim(),
datumVon = von.trim(),
datumBis = bis.trim().ifBlank { null },
untertitel = untertitel.trim(),
ort = ort.trim().ifBlank { StoreV2.vereine.find { it.id == selectedVereinId }?.ort ?: "" },
logoUrl = logoUrl.trim().ifBlank { null }
)
sponsorenText.split(",").filter { it.isNotBlank() }.forEach {
v.sponsoren.add(it.trim())
}
StoreV2.addEventFirst(selectedVereinId, v)
onSaved(id, selectedVereinId)
}
},
enabled = when (currentStep) {
1 -> selectedVereinId != 0L
2 -> {
val dVon = try {
LocalDate.parse(von, dateFormatter)
} catch (e: Exception) {
null
}
val dBis = try {
LocalDate.parse(bis, dateFormatter)
} catch (e: Exception) {
null
}
val rangeInvalid = dVon != null && dBis != null && dBis.isBefore(dVon)
titel.isNotBlank() && von.isNotBlank() && !rangeInvalid
}
3 -> true
else -> false
}
) {
Text(if (currentStep == 3) "Veranstaltung final anlegen" else "Weiter")
}
}
}