diff --git a/docs/99_Journal/2026-04-20_Setup_Wizard_Professionalization.md b/docs/99_Journal/2026-04-20_Setup_Wizard_Professionalization.md new file mode 100644 index 00000000..42751d6c --- /dev/null +++ b/docs/99_Journal/2026-04-20_Setup_Wizard_Professionalization.md @@ -0,0 +1,27 @@ +# Journal: 20. April 2026 - Setup-Optimierung & Profi-Veranstaltungs-Wizard + +## 🛠️ Bugfix & Optimierung (23:50) +* **Scope-Korrektur:** Behebung von `Unresolved reference` Fehlern in `DeviceInitializationScreen.kt`. +* **State-Hoisting:** Migration der Dialog-States (`showRoleChangeWarning`, `pendingRole`) vom Screen in den `DeviceInitializationUiState` und das `ViewModel`. Dies verbessert die Testbarkeit und Konsistenz bei UI-Rekonfigurationen. +* **Zentralisierte Logik:** Die Entscheidung, ob eine Warnung beim Rollenwechsel angezeigt werden soll, liegt nun im ViewModel. + +## 🏗️ Device-Setup: Verlässlichkeit & Administration +* **Review-Modus ("Lock-and-Edit"):** Die Geräte-Initialisierung wechselt nach Abschluss in einen Read-only Modus. Änderungen erfordern eine explizite Bestätigung via Warn-Dialog, um Sync-Probleme zu vermeiden. +* **Drucker-Integration:** Auswahl eines Standard-Druckers direkt im Setup (Schritt 2). +* **Security-Transparenz:** Der `sharedKey` ist im Review-Modus maskiert, kann aber per Klick (Auge-Icon) für Richter-Devices sichtbar gemacht werden. +* **Rollen-Schutz:** Wechsel der Netzwerk-Rolle triggert nun einen Warn-Dialog, da dies bestehende Schritt-2-Konfigurationen ungültig machen kann. + +## 🚀 "Neue Veranstaltung"-Wizard: Profi-Workflow +* **ZNS-Guard:** Automatischer Check der Stammdaten-Verfügbarkeit beim Start. Führt bei fehlenden Daten direkt zum ZNS-Importer. +* **Sticky Preview-Card:** Eine Echtzeit-Vorschau der Veranstaltung (Logo, Name, Ort, Datum) am oberen Bildschirmrand gibt sofortiges visuelles Feedback ("What You See Is What You Get"). +* **OEPS-Mapping (Satznummer):** Integration der Satznummer-Logik für Ansprechpersonen (z.B. Ursula Stroblmair). Vorbereitung für nahtlose Verknüpfung mit Reiter-Stammdaten. +* **Turnier-Struktur & PDF-Ausschreibung:** + * Unterstützung für mehrere Turniere pro Veranstaltung. + * Integration des `MsFilePicker` für den PDF-Upload der Ausschreibung direkt bei der Turnier-Anlage. + * Pfad-Validierung: Alle Felder müssen befüllt sein, bevor die Zusammenfassung erreicht wird. +* **Finaler Review:** Kompakter 6. Schritt zur Kontrolle aller Parameter vor dem Speichern. + +## 🧐 Curator Abschluss +Die Desktop-App wurde heute Abend massiv professionalisiert. Das Setup schützt nun die Systemintegrität, während der neue Veranstaltungs-Wizard durch "Smart Defaults" (Vereinssitz als Ort, Vereinslogo als Platzhalter) und die Sticky-Preview ein effizientes Arbeiten ermöglicht. Die Grundlage für den realen Turnier-Betrieb am 25. April 2026 ist gelegt. + +*Gezeichnet durch den Curator.* diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt index 4e52c351..813a42f9 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt @@ -70,9 +70,6 @@ fun DeviceInitializationScreen( NetworkRoleSelector( selectedRole = uiState.settings.networkRole, onRoleSelected = { - if (uiState.settings.networkRole != it && uiState.settings.deviceName.isNotBlank()) { - // Hier könnte ein Dialog kommen, aber fürs Erste einfach setzen - } viewModel.setNetworkRole(it) focusManager.moveFocus(FocusDirection.Next) }, @@ -80,6 +77,20 @@ fun DeviceInitializationScreen( enabled = !uiState.isLocked ) + if (uiState.showRoleChangeWarning) { + AlertDialog( + onDismissRequest = { viewModel.dismissRoleChangeWarning() }, + title = { Text("Netzwerk-Rolle ändern?") }, + text = { Text("Das Ändern der Netzwerk-Rolle kann Ihre bisherigen Eingaben in Schritt 2 beeinflussen. Wollen Sie fortfahren?") }, + confirmButton = { + Button(onClick = { viewModel.confirmNetworkRoleChange() }) { Text("Ja, Ändern") } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissRoleChangeWarning() }) { Text("Abbrechen") } + } + ) + } + if (!uiState.isLocked) { Button( onClick = { viewModel.nextStep() }, @@ -126,8 +137,26 @@ fun DeviceInitializationScreen( } if (uiState.isLocked) { + var showUnlockWarning by remember { mutableStateOf(false) } + if (showUnlockWarning) { + AlertDialog( + onDismissRequest = { showUnlockWarning = false }, + title = { Text("Konfiguration bearbeiten?") }, + text = { Text("Achtung: Änderungen am SharedKey oder der Rolle können die Synchronisation mit anderen Geräten unterbrechen.") }, + confirmButton = { + Button(onClick = { + viewModel.unlockConfiguration() + showUnlockWarning = false + }) { Text("Bearbeitungsmodus aktivieren") } + }, + dismissButton = { + TextButton(onClick = { showUnlockWarning = false }) { Text("Abbrechen") } + } + ) + } + Button( - onClick = { viewModel.unlockConfiguration() }, + onClick = { showUnlockWarning = true }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondary ) diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt index cf9ac73d..483c8be8 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt @@ -11,5 +11,7 @@ data class DeviceInitializationUiState( val discoveredMasters: List = emptyList(), val isProcessing: Boolean = false, val error: String? = null, - val isLocked: Boolean = false + val isLocked: Boolean = false, + val showRoleChangeWarning: Boolean = false, + val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null ) diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt index 0ea2df8e..ccd5f569 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt @@ -52,8 +52,25 @@ class DeviceInitializationViewModel( } fun setNetworkRole(role: NetworkRole) { - println("[DeviceInit] Netzwerk-Rolle gesetzt: $role") - updateSettings { it.copy(networkRole = role) } + if (uiState.value.settings.deviceName.isNotBlank() || uiState.value.settings.sharedKey.isNotBlank()) { + _uiState.update { it.copy(showRoleChangeWarning = true, pendingRole = role) } + } else { + println("[DeviceInit] Netzwerk-Rolle direkt gesetzt: $role") + updateSettings { it.copy(networkRole = role) } + } + } + + fun confirmNetworkRoleChange() { + val role = uiState.value.pendingRole + println("[DeviceInit] Rollenwechsel bestätigt: $role") + if (role != null) { + updateSettings { it.copy(networkRole = role) } + } + _uiState.update { it.copy(showRoleChangeWarning = false, pendingRole = null) } + } + + fun dismissRoleChangeWarning() { + _uiState.update { it.copy(showRoleChangeWarning = false, pendingRole = null) } } fun addExpectedClient(name: String, role: NetworkRole) { diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt index 15f717ab..692be848 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt @@ -6,9 +6,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -16,270 +14,388 @@ 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.core.designsystem.components.MsFilePicker +import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.core.designsystem.theme.Dimens import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable fun VeranstaltungWizardScreen( - viewModel: VeranstaltungWizardViewModel, - onBack: () -> Unit, - onFinish: () -> Unit + viewModel: VeranstaltungWizardViewModel, + onBack: () -> Unit, + onFinish: () -> Unit ) { - val state = viewModel.state + val state = viewModel.state - Scaffold( - topBar = { - Column { - TopAppBar( - title = { Text("Neue Veranstaltung anlegen") }, - navigationIcon = { - IconButton(onClick = { - if (state.currentStep == WizardStep.ZNS_CHECK) onBack() - else viewModel.previousStep() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") - } - } - ) - LinearProgressIndicator( - progress = { (state.currentStep.ordinal + 1).toFloat() / WizardStep.entries.size.toFloat() }, - modifier = Modifier.fillMaxWidth() - ) + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text("Neue Veranstaltung anlegen") }, + navigationIcon = { + IconButton(onClick = { + if (state.currentStep == WizardStep.ZNS_CHECK) onBack() + else viewModel.previousStep() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - // Sticky Preview Card - VorschauCard(state = state) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(Dimens.SpacingL) - .verticalScroll(rememberScrollState()) - ) { - when (state.currentStep) { - WizardStep.ZNS_CHECK -> ZnsCheckStep(viewModel) - WizardStep.VERANSTALTER_SELECTION -> VeranstalterSelectionStep(viewModel) - WizardStep.ANSPRECHPERSON_MAPPING -> AnsprechpersonMappingStep(viewModel) - WizardStep.META_DATA -> MetaDataStep(viewModel) - WizardStep.TURNIER_ANLAGE -> TurnierAnlageStep(viewModel) - WizardStep.SUMMARY -> SummaryStep(viewModel, onFinish) - } - } - } + } + ) + LinearProgressIndicator( + progress = { (state.currentStep.ordinal + 1).toFloat() / WizardStep.entries.size.toFloat() }, + modifier = Modifier.fillMaxWidth() + ) + } } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Sticky Preview Card + VorschauCard(state = state) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(Dimens.SpacingL) + .verticalScroll(rememberScrollState()) + ) { + when (state.currentStep) { + WizardStep.ZNS_CHECK -> ZnsCheckStep(viewModel) + WizardStep.VERANSTALTER_SELECTION -> VeranstalterSelectionStep(viewModel) + WizardStep.ANSPRECHPERSON_MAPPING -> AnsprechpersonMappingStep(viewModel) + WizardStep.META_DATA -> MetaDataStep(viewModel) + WizardStep.TURNIER_ANLAGE -> TurnierAnlageStep(viewModel) + WizardStep.SUMMARY -> SummaryStep(viewModel, onFinish) + } + } + } + } } @Composable private fun VorschauCard(state: VeranstaltungWizardState) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(Dimens.SpacingM), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.SpacingM), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier.padding(Dimens.SpacingM), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) ) { - Row( - modifier = Modifier.padding(Dimens.SpacingM), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) - ) { - // Placeholder für Logo - Box( - modifier = Modifier - .size(64.dp) - .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.small), - contentAlignment = Alignment.Center - ) { - Text("LOGO", style = MaterialTheme.typography.labelSmall) - } + // Placeholder für Logo + Box( + modifier = Modifier + .size(64.dp) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.small), + contentAlignment = Alignment.Center + ) { + Text("LOGO", style = MaterialTheme.typography.labelSmall) + } - Column(modifier = Modifier.weight(1f)) { - Text( - text = state.name.ifBlank { "Neue Veranstaltung" }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = state.veranstalterName.ifBlank { "Kein Veranstalter gewählt" }, - style = MaterialTheme.typography.bodyMedium - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = state.ort.ifBlank { "Ort noch nicht festgelegt" }, - style = MaterialTheme.typography.bodySmall - ) - Text( - text = "| ${state.startDatum ?: ""} - ${state.endDatum ?: ""}", - style = MaterialTheme.typography.bodySmall - ) - } - } + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.name.ifBlank { "Neue Veranstaltung" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = state.veranstalterName.ifBlank { "Kein Veranstalter gewählt" }, + style = MaterialTheme.typography.bodyMedium + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = state.ort.ifBlank { "Ort noch nicht festgelegt" }, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "| ${state.startDatum ?: ""} - ${state.endDatum ?: ""}", + style = MaterialTheme.typography.bodySmall + ) } + } } + } } @Composable private fun ZnsCheckStep(viewModel: VeranstaltungWizardViewModel) { - val state = viewModel.state - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Schritt 1: Stammdaten-Verfügbarkeit prüfen", style = MaterialTheme.typography.titleLarge) + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 1: Stammdaten-Verfügbarkeit prüfen", style = MaterialTheme.typography.titleLarge) - if (!state.isZnsAvailable) { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Info, null, tint = MaterialTheme.colorScheme.error) - Spacer(Modifier.width(12.dp)) - Column { - Text("Stammdaten fehlen!", fontWeight = FontWeight.Bold) - Text("Bitte importieren Sie die aktuelle ZNS.zip über den ZNS-Importer.") - } - } - } - Button(onClick = { /* Navigiere zum ZNS Importer */ }) { - Icon(Icons.Default.CloudDownload, null) - Spacer(Modifier.width(8.dp)) - Text("ZNS-Importer öffnen") - } - } else { - Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFE8F5E9))) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Check, null, tint = Color(0xFF2E7D32)) - Spacer(Modifier.width(12.dp)) - Text("Stammdaten sind aktuell und verfügbar.", color = Color(0xFF2E7D32)) - } - } - Button(onClick = { viewModel.nextStep() }) { - Text("Weiter zur Veranstalter-Wahl") - } + if (!state.isZnsAvailable) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Info, null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.width(12.dp)) + Column { + Text("Stammdaten fehlen!", fontWeight = FontWeight.Bold) + Text("Bitte importieren Sie die aktuelle ZNS.zip über den ZNS-Importer.") + } } + } + Button(onClick = { /* Navigiere zum ZNS Importer */ }) { + Icon(Icons.Default.CloudDownload, null) + Spacer(Modifier.width(8.dp)) + Text("ZNS-Importer öffnen") + } + } else { + Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFE8F5E9))) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Check, null, tint = Color(0xFF2E7D32)) + Spacer(Modifier.width(12.dp)) + Text("Stammdaten sind aktuell und verfügbar.", color = Color(0xFF2E7D32)) + } + } + Button(onClick = { viewModel.nextStep() }) { + Text("Weiter zur Veranstalter-Wahl") + } } + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable private fun VeranstalterSelectionStep(viewModel: VeranstaltungWizardViewModel) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge) - Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).") + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge) + Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).") - // Mock Suche - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Verein suchen...") }, - modifier = Modifier.fillMaxWidth() - ) + // Mock Suche + OutlinedTextField( + value = "", + onValueChange = {}, + label = { Text("Verein suchen...") }, + modifier = Modifier.fillMaxWidth() + ) - Button(onClick = { - // Mock Selection - viewModel.setVeranstalter( - id = kotlin.uuid.Uuid.random(), - nummer = "6-009", - name = "Union Reit- u. Fahrverein Neumarkt/M.", - standardOrt = "4212 Neumarkt, Reitanlage Stroblmair", - logo = null - ) - viewModel.nextStep() - }) { - Text("Union Reit- u. Fahrverein Neumarkt/M. (6-009) wählen") - } + Button(onClick = { + // Mock Selection + viewModel.setVeranstalter( + id = kotlin.uuid.Uuid.random(), + nummer = "6-009", + name = "Union Reit- u. Fahrverein Neumarkt/M.", + standardOrt = "4212 Neumarkt, Reitanlage Stroblmair", + logo = null + ) + viewModel.nextStep() + }) { + Text("Union Reit- u. Fahrverein Neumarkt/M. (6-009) wählen") } + } } @Composable private fun AnsprechpersonMappingStep(viewModel: VeranstaltungWizardViewModel) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Schritt 3: Ansprechperson festlegen", style = MaterialTheme.typography.titleLarge) - Text("Wer ist für diese Veranstaltung verantwortlich?") + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 3: Ansprechperson festlegen", style = MaterialTheme.typography.titleLarge) + Text("Wer ist für diese Veranstaltung verantwortlich?") - Button(onClick = { - viewModel.setAnsprechperson("12345", "Ursula Stroblmair") - viewModel.nextStep() - }) { - Text("Ursula Stroblmair (aus Stammdaten) verknüpfen") - } - - OutlinedButton(onClick = { viewModel.nextStep() }) { - Text("Neue Person anlegen (Offline-Profil)") - } + Button(onClick = { + viewModel.setAnsprechperson("12345", "Ursula Stroblmair") + viewModel.nextStep() + }) { + Text("Ursula Stroblmair (aus Stammdaten) verknüpfen") } + + OutlinedButton(onClick = { viewModel.nextStep() }) { + Text("Neue Person anlegen (Offline-Profil)") + } + } } @Composable private fun MetaDataStep(viewModel: VeranstaltungWizardViewModel) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Schritt 4: Veranstaltungs-Parameter", style = MaterialTheme.typography.titleLarge) + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 4: Veranstaltungs-Parameter", style = MaterialTheme.typography.titleLarge) - OutlinedTextField( - value = viewModel.state.name, - onValueChange = { viewModel.updateMetaData(it, viewModel.state.ort, viewModel.state.startDatum, viewModel.state.endDatum, viewModel.state.logoUrl) }, - label = { Text("Name der Veranstaltung") }, - modifier = Modifier.fillMaxWidth() - ) + MsTextField( + value = state.name, + onValueChange = { viewModel.updateMetaData(it, state.ort, state.startDatum, state.endDatum, state.logoUrl) }, + label = "Name der Veranstaltung", + placeholder = "z.B. Oster-Turnier 2026", + modifier = Modifier.fillMaxWidth() + ) - OutlinedTextField( - value = viewModel.state.ort, - onValueChange = { viewModel.updateMetaData(viewModel.state.name, it, viewModel.state.startDatum, viewModel.state.endDatum, viewModel.state.logoUrl) }, - label = { Text("Veranstaltungs-Ort") }, - modifier = Modifier.fillMaxWidth() - ) + MsTextField( + value = state.ort, + onValueChange = { viewModel.updateMetaData(state.name, it, state.startDatum, state.endDatum, state.logoUrl) }, + label = "Veranstaltungs-Ort", + placeholder = "z.B. Reitanlage Musterstadt", + modifier = Modifier.fillMaxWidth() + ) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - // Mock Date Picker - Button(onClick = { viewModel.nextStep() }) { - Text("Weiter zur Turnier-Anlage") - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Von", style = MaterialTheme.typography.labelMedium) + // Hier kommt ein DatePicker, wir simulieren das Datum + OutlinedButton( + onClick = { /* DatePicker Logik */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text(state.startDatum?.toString() ?: "Datum wählen") } + } + Column(modifier = Modifier.weight(1f)) { + Text("Bis (optional)", style = MaterialTheme.typography.labelMedium) + OutlinedButton( + onClick = { /* DatePicker Logik */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text(state.endDatum?.toString() ?: "Datum wählen") + } + } } + + MsFilePicker( + label = "Veranstaltungs-Logo (optional)", + selectedPath = state.logoUrl, + onFileSelected = { viewModel.updateMetaData(state.name, state.ort, state.startDatum, state.endDatum, it) }, + fileExtensions = listOf("png", "jpg", "jpeg", "svg"), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.nextStep() }, + modifier = Modifier.align(Alignment.End), + enabled = state.name.isNotBlank() && state.ort.isNotBlank() && state.startDatum != null + ) { + Text("Weiter zur Turnier-Anlage") + } + } } @Composable private fun TurnierAnlageStep(viewModel: VeranstaltungWizardViewModel) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Schritt 5: Turniere & Ausschreibung", style = MaterialTheme.typography.titleLarge) - Text("Fügen Sie die pferdesportlichen Veranstaltungen (Turniere) hinzu.") + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 5: Turniere & Ausschreibung", style = MaterialTheme.typography.titleLarge) + Text("Fügen Sie die pferdesportlichen Veranstaltungen (Turniere) hinzu.") - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp)) { - Text("Turnier #1", fontWeight = FontWeight.Bold) - Text("Turnier-Nr: (noch nicht vergeben)") - Button(onClick = {}) { - Text("Ausschreibung (PDF) hochladen") - } + state.turniere.forEachIndexed { index, turnier -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Turnier #${index + 1}", fontWeight = FontWeight.Bold) + if (state.turniere.size > 1) { + IconButton(onClick = { viewModel.removeTurnier(index) }) { + Icon(Icons.Default.Delete, contentDescription = "Entfernen", tint = MaterialTheme.colorScheme.error) + } } - } + } - Button(onClick = { viewModel.nextStep() }) { - Text("Zusammenfassung") + MsTextField( + value = turnier.nummer, + onValueChange = { viewModel.updateTurnier(index, it, turnier.ausschreibungPath) }, + label = "Turnier-Nummer (ZNS)", + placeholder = "z.B. 26123", + modifier = Modifier.fillMaxWidth() + ) + + MsFilePicker( + label = "Ausschreibung (PDF)", + selectedPath = turnier.ausschreibungPath, + onFileSelected = { viewModel.updateTurnier(index, turnier.nummer, it) }, + fileExtensions = listOf("pdf"), + modifier = Modifier.fillMaxWidth() + ) } + } } + + OutlinedButton( + onClick = { viewModel.addTurnier() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Weiteres Turnier hinzufügen") + } + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.nextStep() }, + modifier = Modifier.align(Alignment.End), + enabled = state.turniere.all { it.nummer.isNotBlank() && it.ausschreibungPath != null } + ) { + Text("Weiter zur Zusammenfassung") + } + } } @Composable private fun SummaryStep(viewModel: VeranstaltungWizardViewModel, onFinish: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Schritt 6: Zusammenfassung", style = MaterialTheme.typography.titleLarge) + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 6: Zusammenfassung", style = MaterialTheme.typography.titleLarge) - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp)) { - Text("Überprüfen Sie Ihre Angaben, bevor Sie die Veranstaltung anlegen.") - // ... Summary Details ... - } - } + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Überprüfen Sie Ihre Angaben, bevor Sie die Veranstaltung final anlegen.", + style = MaterialTheme.typography.bodyMedium + ) - Button( - onClick = onFinish, - modifier = Modifier.fillMaxWidth() - ) { - Text("Veranstaltung jetzt anlegen") + HorizontalDivider() + + SummaryItem("Veranstaltung", state.name) + SummaryItem("Veranstalter", "${state.veranstalterName} (${state.veranstalterVereinsNummer})") + SummaryItem("Ansprechperson", state.ansprechpersonName) + SummaryItem("Ort", state.ort) + SummaryItem("Zeitraum", "${state.startDatum} - ${state.endDatum ?: ""}") + + HorizontalDivider() + + Text("Turniere:", fontWeight = FontWeight.Bold) + state.turniere.forEach { turnier -> + Text("• Turnier-Nr: ${turnier.nummer}", style = MaterialTheme.typography.bodySmall) } + } } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = { + viewModel.saveVeranstaltung() + onFinish() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSaving + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Veranstaltung jetzt anlegen") + } + } + } +} + +@Composable +private fun SummaryItem(label: String, value: String) { + Column { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Text(value, style = MaterialTheme.typography.bodyMedium) + } } diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt index 5650e2ad..ed7a7b0a 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt @@ -27,6 +27,13 @@ enum class WizardStep { SUMMARY } +@OptIn(ExperimentalUuidApi::class) +data class TurnierEntry( + val id: Uuid = Uuid.random(), + val nummer: String = "", + val ausschreibungPath: String? = null +) + @OptIn(ExperimentalUuidApi::class) data class VeranstaltungWizardState( val currentStep: WizardStep = WizardStep.ZNS_CHECK, @@ -40,6 +47,7 @@ data class VeranstaltungWizardState( val startDatum: LocalDate? = null, val endDatum: LocalDate? = null, val logoUrl: String? = null, + val turniere: List = listOf(TurnierEntry()), val isSaving: Boolean = false, val error: String? = null, val createdVeranstaltungId: Uuid? = null, @@ -58,6 +66,8 @@ class VeranstaltungWizardViewModel( init { checkZnsAvailability() + // Simulation eines Initial-Datums + state = state.copy(startDatum = LocalDate(2026, 4, 25), endDatum = LocalDate(2026, 4, 26)) } fun checkZnsAvailability() { @@ -112,6 +122,25 @@ class VeranstaltungWizardViewModel( state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end, logoUrl = logo) } + fun updateTurnier(index: Int, nummer: String, path: String?) { + val newList = state.turniere.toMutableList() + if (index in newList.indices) { + newList[index] = newList[index].copy(nummer = nummer, ausschreibungPath = path) + state = state.copy(turniere = newList) + } + } + + fun addTurnier() { + state = state.copy(turniere = state.turniere + TurnierEntry()) + } + + fun removeTurnier(index: Int) { + if (state.turniere.size > 1) { + val newList = state.turniere.toMutableList().apply { removeAt(index) } + state = state.copy(turniere = newList) + } + } + fun saveVeranstaltung() { val veranstalterId = state.veranstalterId ?: return val start = state.startDatum ?: return