Compare commits

...

5 Commits

Author SHA1 Message Date
30b53584f8 chore: implementiere Suche nach Veranstalter via OEPS-Nummer, verbessere UI-Flow im Veranstaltungs-Wizard und erweitere VereinRepository um OEPS-Abfrage
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 00:50:14 +02:00
c1327f3186 chore: erweitere Veranstaltungs-Wizard um Ansprechperson-Anzeige, verbessere Fehlerhandling bei fehlenden Stammdaten und implementiere MsStringDropdown
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 23:53:43 +02:00
7a2c5700f9 chore: füge Warn-Dialoge für Rollenwechsel und Bearbeitungsmodus hinzu, verbessere Zustandshandhabung im Device-Setup und implementiere Turnierverwaltung im Veranstaltungs-Wizard
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 23:48:53 +02:00
5b8ef5ea2d chore: implementiere Lockscreen-Logik für Geräte- und Veranstaltungsinitialisierung, füge Zustandsprüfungen und neue UI-Komponenten hinzu
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 23:37:58 +02:00
db58c24613 chore: entferne settings.json und Veranstaltungskomponenten, refaktoriere Veranstaltungsverwaltung und implementiere StoreVeranstaltungRepository
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 18:33:45 +02:00
31 changed files with 1407 additions and 462 deletions

View File

@ -32,6 +32,16 @@ Die Nachmittags-Session am 20. April 2026 wurde erfolgreich abgeschlossen. Die g
- **API-Bereinigung:** Beseitigung ungenutzter Properties (z.B. `TopBarTextColor`) und Korrektur veralteter API-Signaturen in den Screen-Injektionen. - **API-Bereinigung:** Beseitigung ungenutzter Properties (z.B. `TopBarTextColor`) und Korrektur veralteter API-Signaturen in den Screen-Injektionen.
- **Fehlerbehebung:** Beseitigung von Kompilerfehlern in `NavRail.kt` (Tooltip-Positionierung) und Bereinigung ungenutzter Parameter in `ContentArea.kt`. - **Fehlerbehebung:** Beseitigung von Kompilerfehlern in `NavRail.kt` (Tooltip-Positionierung) und Bereinigung ungenutzter Parameter in `ContentArea.kt`.
### 4. Fachlicher Einstieg & Start-Screen (Punkt 4)
- **Extraktion:** Die Veranstaltungs-Verwaltung wurde aus der Shell in das Feature-Modul `veranstaltung-feature` extrahiert.
- **Architektur:** Implementierung von `VeranstaltungManagementViewModel` und `VeranstaltungRepository` (ADR-0024 konform).
- **Entkoppelung:** Einführung eines domänenspezifischen `VeranstaltungModel` zur Trennung von Shell-Datenstrukturen.
- **UI/UX (Vision_03):**
- High-Density Layout mit optimierten Cards und Spacings.
- Implementierung einer Echtzeit-Suche und Status-Filtern (Alle, In Planung, Aktiv, Abgeschlossen).
- Konsistente Status-Badges nach dem offiziellen Farbschema.
- **Cleanup:** Löschung der redundanten `VeranstaltungVerwaltung.kt` in der Desktop-Shell.
## 📐 Architektur-Check (ADR-0024) ## 📐 Architektur-Check (ADR-0024)
- **Self-Contained:** Feature-Module verwalten ihren State; Shell reagiert auf Events. - **Self-Contained:** Feature-Module verwalten ihren State; Shell reagiert auf Events.
- **Reaktivität:** UI reagiert sofort auf Konfigurationsänderungen (`settings.json`). - **Reaktivität:** UI reagiert sofort auf Konfigurationsänderungen (`settings.json`).
@ -39,3 +49,9 @@ Die Nachmittags-Session am 20. April 2026 wurde erfolgreich abgeschlossen. Die g
--- ---
*Dokumentation erstellt durch den Curator im Rahmen des "Meldestelle"-Protokolls.* *Dokumentation erstellt durch den Curator im Rahmen des "Meldestelle"-Protokolls.*
### 5. Hotfixes & Stabilisierung (Post-Release Review)
- **Navigations-Sicherheit:** Das Logo-Icon in der `NavRail` wurde gesperrt (`enabled = isConfigured`), um unautorisierte Zugriffe vor dem Onboarding zu unterbinden.
- **Koin-Fix:** Registrierung des `veranstalterModule` in der `main.kt` nachgeholt, um Abstürze beim Erstellen neuer Veranstaltungen zu beheben.
- **UI-Polishing:** Entfernung des irritierenden Zurück-Pfeils in der Konnektivitäts-Diagnose (`PingScreen`), um die UX-Klarheit zu erhöhen.
- **Home-Navigation-Sperre:** Das Home-Icon im Header wurde ebenfalls an den `isConfigured`-Status gebunden, um die Start-Sequenz final abzusichern.

View File

@ -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.*

View File

@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -23,8 +22,27 @@ import at.mocode.frontend.core.designsystem.theme.Dimens
@Composable @Composable
fun MsCard( fun MsCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), // Dünner Rahmen
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) // Kein Schatten
) {
Column(
modifier = Modifier.padding(Dimens.SpacingS) // Kompaktes Padding innen
) {
content()
}
}
} else {
Card( Card(
modifier = modifier, modifier = modifier,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
@ -40,18 +58,6 @@ fun MsCard(
content() content()
} }
} }
}
} }
// Preview für IDE (muss in jvmMain liegen um in IDEA gerendert zu werden,
// oder hier bleiben als Dokumentation)
@Composable
fun MsCardPreviewContent() {
MaterialTheme {
Column(modifier = Modifier.padding(16.dp)) {
MsCard {
Text("Dies ist eine MsCard", style = MaterialTheme.typography.bodyMedium)
Text("Mit High-Density Content.", style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@ -0,0 +1,94 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp
/**
* Ein generischer Dropdown zur Auswahl von Strings (z. B. Druckernamen).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MsStringDropdown(
label: String,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
enabled: Boolean = true,
isError: Boolean = false,
errorMessage: String? = null
) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { if (enabled) expanded = it }
) {
OutlinedTextField(
value = selectedOption,
onValueChange = {},
readOnly = true,
label = { Text(label, style = MaterialTheme.typography.bodySmall) },
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled)
.fillMaxWidth()
.onKeyEvent {
if (it.key == Key.Enter || it.key == Key.DirectionCenter) {
if (it.type == KeyEventType.KeyUp) {
expanded = !expanded
}
true
} else {
false
}
},
isError = isError,
enabled = enabled,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
shape = MaterialTheme.shapes.small
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
text = {
Text(
text = option,
style = MaterialTheme.typography.bodyMedium
)
},
onClick = {
onOptionSelected(option)
expanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
if (isError && errorMessage != null) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}

View File

@ -9,6 +9,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -72,9 +73,25 @@ fun DeviceInitializationScreen(
viewModel.setNetworkRole(it) viewModel.setNetworkRole(it)
focusManager.moveFocus(FocusDirection.Next) focusManager.moveFocus(FocusDirection.Next)
}, },
modifier = Modifier.focusRequester(roleSelectorFocus) modifier = Modifier.focusRequester(roleSelectorFocus),
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( Button(
onClick = { viewModel.nextStep() }, onClick = { viewModel.nextStep() },
modifier = Modifier modifier = Modifier
@ -90,10 +107,19 @@ fun DeviceInitializationScreen(
Text("Weiter") Text("Weiter")
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
} }
} else {
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End)
) {
Text("Zur Konfiguration")
Icon(Icons.AutoMirrored.Filled.ArrowForward, null)
}
}
} }
} }
} else { } else {
// PHASE 2: ROLLENSPEZIFISCH (JVM spezifische Implementierung folgt) // PHASE 2 & Review
DeviceInitializationConfig( DeviceInitializationConfig(
uiState = uiState, uiState = uiState,
viewModel = viewModel viewModel = viewModel
@ -110,17 +136,47 @@ fun DeviceInitializationScreen(
Text("Zurück zur Rollenauswahl") Text("Zurück zur Rollenauswahl")
} }
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 = { showUnlockWarning = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("Konfiguration bearbeiten")
Icon(Icons.Default.Edit, null, Modifier.padding(start = 8.dp))
}
} else {
Button( Button(
onClick = { viewModel.completeInitialization() }, onClick = { viewModel.completeInitialization() },
enabled = DeviceInitializationValidator.canContinue(uiState.settings) enabled = DeviceInitializationValidator.canContinue(uiState.settings)
) { ) {
Text("Konfiguration abschließen") Text("Konfiguration finalisieren & Sperren")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp)) Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
} }
} }
} }
} }
} }
}
} }
@Composable @Composable

View File

@ -10,5 +10,8 @@ data class DeviceInitializationUiState(
val settings: DeviceInitializationSettings = DeviceInitializationSettings(), val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
val discoveredMasters: List<DiscoveredService> = emptyList(), val discoveredMasters: List<DiscoveredService> = emptyList(),
val isProcessing: Boolean = false, val isProcessing: Boolean = false,
val error: String? = null val error: String? = null,
val isLocked: Boolean = false,
val showRoleChangeWarning: Boolean = false,
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null
) )

View File

@ -1,16 +1,14 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package at.mocode.frontend.features.device.initialization.presentation package at.mocode.frontend.features.device.initialization.presentation
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -54,9 +52,26 @@ class DeviceInitializationViewModel(
} }
fun setNetworkRole(role: NetworkRole) { fun setNetworkRole(role: NetworkRole) {
println("[DeviceInit] Netzwerk-Rolle gesetzt: $role") if (uiState.value.settings.networkRole != role && (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) } 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) { fun addExpectedClient(name: String, role: NetworkRole) {
println("[DeviceInit] Erwarteter Client hinzugefügt: $name ($role)") println("[DeviceInit] Erwarteter Client hinzugefügt: $name ($role)")
@ -75,7 +90,13 @@ class DeviceInitializationViewModel(
} }
fun completeInitialization() { fun completeInitialization() {
println("[DeviceInit] Konfiguration abgeschlossen. Speichere Einstellungen...") println("[DeviceInit] Konfiguration wird finalisiert...")
_uiState.update { it.copy(isLocked = true) }
onInitializationComplete(_uiState.value.settings) onInitializationComplete(_uiState.value.settings)
} }
fun unlockConfiguration() {
println("[DeviceInit] Konfiguration entsperrt für Änderungen.")
_uiState.update { it.copy(isLocked = false) }
}
} }

View File

@ -18,16 +18,18 @@ import at.mocode.frontend.features.device.initialization.domain.model.NetworkRol
fun NetworkRoleSelector( fun NetworkRoleSelector(
selectedRole: NetworkRole, selectedRole: NetworkRole,
onRoleSelected: (NetworkRole) -> Unit, onRoleSelected: (NetworkRole) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
enabled: Boolean = true
) { ) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
NetworkRoleCard( NetworkRoleCard(
title = "Master (Host)", title = "Master (Host)",
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.", description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
isSelected = selectedRole == NetworkRole.MASTER, isSelected = selectedRole == NetworkRole.MASTER,
onClick = { onRoleSelected(NetworkRole.MASTER) }, onClick = { if (enabled) onRoleSelected(NetworkRole.MASTER) },
enabled = enabled,
modifier = Modifier.onKeyEvent { modifier = Modifier.onKeyEvent {
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) { if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
onRoleSelected(NetworkRole.MASTER) onRoleSelected(NetworkRole.MASTER)
true true
} else false } else false
@ -38,9 +40,10 @@ fun NetworkRoleSelector(
title = "Client", title = "Client",
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.", description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
isSelected = selectedRole == NetworkRole.CLIENT, isSelected = selectedRole == NetworkRole.CLIENT,
onClick = { onRoleSelected(NetworkRole.CLIENT) }, onClick = { if (enabled) onRoleSelected(NetworkRole.CLIENT) },
enabled = enabled,
modifier = Modifier.onKeyEvent { modifier = Modifier.onKeyEvent {
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) { if (enabled && (it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
onRoleSelected(NetworkRole.CLIENT) onRoleSelected(NetworkRole.CLIENT)
true true
} else false } else false
@ -55,24 +58,36 @@ private fun NetworkRoleCard(
description: String, description: String,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
enabled: Boolean = true
) { ) {
Surface( Surface(
onClick = onClick, onClick = onClick,
enabled = enabled,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, color = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surfaceVariant
},
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton( RadioButton(
selected = isSelected, selected = isSelected,
onClick = null onClick = null,
enabled = enabled
) )
Column { Column {
Text(title, style = MaterialTheme.typography.labelLarge) Text(
title,
style = MaterialTheme.typography.labelLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
Text( Text(
description, description,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall,
color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
) )
} }
} }

View File

@ -30,9 +30,11 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsEnumDropdown import at.mocode.frontend.core.designsystem.components.MsEnumDropdown
import at.mocode.frontend.core.designsystem.components.MsFilePicker import at.mocode.frontend.core.designsystem.components.MsFilePicker
import at.mocode.frontend.core.designsystem.components.MsStringDropdown
import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import javax.print.PrintServiceLookup
@Composable @Composable
actual fun DeviceInitializationConfig( actual fun DeviceInitializationConfig(
@ -60,7 +62,8 @@ actual fun DeviceInitializationConfig(
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
modifier = Modifier.focusRequester(deviceNameFocus) modifier = Modifier.focusRequester(deviceNameFocus),
enabled = !uiState.isLocked
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
@ -71,14 +74,15 @@ actual fun DeviceInitializationConfig(
placeholder = "Mindestens 8 Zeichen", placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible || uiState.isLocked) VisualTransformation.None else PasswordVisualTransformation(),
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) } onNext = { focusManager.moveFocus(FocusDirection.Next) }
), ),
modifier = Modifier.focusRequester(sharedKeyFocus), modifier = Modifier.focusRequester(sharedKeyFocus),
trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
onTrailingIconClick = { passwordVisible = !passwordVisible } onTrailingIconClick = { passwordVisible = !passwordVisible },
enabled = !uiState.isLocked
) )
MsFilePicker( MsFilePicker(
@ -88,7 +92,22 @@ actual fun DeviceInitializationConfig(
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
}, },
directoryOnly = true, directoryOnly = true,
modifier = Modifier.focusRequester(backupPathFocus) modifier = Modifier.focusRequester(backupPathFocus),
enabled = !uiState.isLocked
)
val printers = remember {
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.sorted()
}
MsStringDropdown(
label = "Standard-Drucker",
options = printers,
selectedOption = settings.defaultPrinter,
onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },
placeholder = "Drucker auswählen...",
enabled = !uiState.isLocked,
modifier = Modifier.padding(bottom = 8.dp)
) )
if (settings.networkRole == NetworkRole.MASTER) { if (settings.networkRole == NetworkRole.MASTER) {
@ -97,11 +116,13 @@ actual fun DeviceInitializationConfig(
value = settings.syncInterval.toFloat(), value = settings.syncInterval.toFloat(),
onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } },
valueRange = 1f..60f, valueRange = 1f..60f,
steps = 59 steps = 59,
enabled = !uiState.isLocked
) )
} else { } else {
// Button zum Abschließen für Clients, da diese keinen Slider/Clients haben // Button zum Abschließen für Clients, da diese keinen Slider/Clients haben
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (!uiState.isLocked) {
Button( Button(
onClick = { viewModel.completeInitialization() }, onClick = { viewModel.completeInitialization() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -110,8 +131,9 @@ actual fun DeviceInitializationConfig(
Text("Konfiguration abschließen") Text("Konfiguration abschließen")
} }
} }
}
if (settings.networkRole == NetworkRole.MASTER) { if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall) Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
@ -223,7 +245,7 @@ actual fun DeviceInitializationConfig(
Text("Client hinzufügen") Text("Client hinzufügen")
} }
} }
} else { } else if (settings.networkRole != NetworkRole.MASTER) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall) Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall)
@ -255,6 +277,18 @@ actual fun DeviceInitializationConfig(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (settings.networkRole == NetworkRole.MASTER && uiState.isLocked) {
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall)
settings.expectedClients.forEach { client ->
ListItem(
headlineContent = { Text(client.name) },
trailingContent = {
SuggestionChip(onClick = {}, label = { Text(client.role.name) })
}
)
}
}
} }
} }
} }

View File

@ -211,7 +211,7 @@ fun VeranstalterAuswahlScreen(
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) { ) {
Text("Weiter zur Turnier-Konfiguration") Text("Veranstalter auswählen & Weiter")
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp)) Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp))
} }

View File

@ -32,6 +32,8 @@ kotlin {
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
implementation(projects.core.coreDomain) implementation(projects.core.coreDomain)
implementation(projects.frontend.core.auth) implementation(projects.frontend.core.auth)
implementation(projects.frontend.features.vereinFeature)
implementation(projects.frontend.features.deviceInitialization)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.runtime) implementation(compose.runtime)

View File

@ -0,0 +1,10 @@
package at.mocode.veranstaltung.feature.di
import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel
import at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel
import org.koin.dsl.module
val veranstaltungModule = module {
factory { VeranstaltungManagementViewModel(get()) }
factory { VeranstaltungWizardViewModel(get(), get(), get()) }
}

View File

@ -0,0 +1,16 @@
package at.mocode.veranstaltung.feature.domain.model
import at.mocode.core.domain.model.VeranstaltungsStatusE
import kotlinx.datetime.LocalDate
data class VeranstaltungModel(
val id: Long,
val veranstalterId: Long,
val titel: String,
val datumVon: LocalDate,
val datumBis: LocalDate?,
val status: VeranstaltungsStatusE,
val ort: String,
val vereinName: String,
val logoUrl: String? = null
)

View File

@ -0,0 +1,10 @@
package at.mocode.veranstaltung.feature.domain.repository
import at.mocode.veranstaltung.feature.domain.model.VeranstaltungModel
import kotlinx.coroutines.flow.Flow
interface VeranstaltungRepository {
fun getAllVeranstaltungen(): Flow<List<VeranstaltungModel>>
fun getVeranstaltungenByStatus(status: String): Flow<List<VeranstaltungModel>>
suspend fun deleteVeranstaltung(veranstalterId: Long, veranstaltungId: Long)
}

View File

@ -3,65 +3,150 @@ package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Place
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.veranstaltung.feature.domain.model.VeranstaltungModel
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.flow.firstOrNull
/**
* Detailansicht einer bestehenden Veranstaltung (Vision_03: /veranstaltung/{id}).
* Zeigt Übersicht-Tab mit Turniere-Section.
* TODO: Echte Daten laden (Phase 4/5).
*/
@Composable @Composable
fun VeranstaltungDetailScreen( fun VeranstaltungDetailScreen(
veranstaltungId: Long, veranstaltungId: Long,
repository: VeranstaltungRepository,
onBack: () -> Unit, onBack: () -> Unit,
onTurnierNeu: () -> Unit, onTurnierNeu: () -> Unit,
onTurnierOeffnen: (Long) -> Unit, onTurnierOpen: (Long) -> Unit,
onNavigateToVeranstalterProfil: (Long) -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { var veranstaltung by remember { mutableStateOf<VeranstaltungModel?>(null) }
// Toolbar var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(veranstaltungId) {
isLoading = true
// Einfache Abfrage über das Repository (In Phase 14 wird das ViewModel mit StateFlow)
val all = repository.getAllVeranstaltungen().firstOrNull()
veranstaltung = all?.find { it.id == veranstaltungId }
isLoading = false
}
if (isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val event = veranstaltung
if (event == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Veranstaltung #$veranstaltungId nicht gefunden.")
}
return
}
Column(Modifier.fillMaxSize().padding(Dimens.SpacingM)) {
// Header
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
} }
Spacer(Modifier.width(8.dp)) Column {
Text( Text(
text = "Veranstaltung #$veranstaltungId", text = event.titel,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "${event.datumVon} - ${event.datumBis ?: ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Spacer(Modifier.weight(1f))
PrimaryTabRow(selectedTabIndex = 0) { Button(onClick = onTurnierNeu) {
Tab(selected = true, onClick = {}, text = { Text("Veranstaltung Übersicht") }) Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(Dimens.SpacingXS))
Text("Turnier hinzufügen")
}
} }
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { Spacer(Modifier.height(Dimens.SpacingL))
// Turniere-Section
Row( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
modifier = Modifier.fillMaxWidth(), // Linke Spalte: Details & Turniere
horizontalArrangement = Arrangement.SpaceBetween, Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
verticalAlignment = Alignment.CenterVertically, // KPIs
) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
Text("Turniere", style = MaterialTheme.typography.titleMedium) DetailKpiCard("Status", event.status.name, Icons.Default.Info, Modifier.weight(1f))
OutlinedButton(onClick = onTurnierNeu) { DetailKpiCard("Ort", event.ort, Icons.Default.Place, Modifier.weight(1f))
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neues Turnier")
} }
}
Spacer(Modifier.height(16.dp)) Text(
PlaceholderContent( text = "Turniere in dieser Veranstaltung",
title = "Noch keine Turniere", style = MaterialTheme.typography.titleLarge,
subtitle = "Lege ein neues Turnier für diese Veranstaltung an.", fontWeight = FontWeight.Bold
) )
Card(Modifier.fillMaxWidth()) {
Box(Modifier.padding(Dimens.SpacingXL).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Noch keine Turniere angelegt (Phase 14).", style = MaterialTheme.typography.bodyMedium)
}
}
}
// Rechte Spalte: Veranstalter Information & Aktionen
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
Card {
Column(Modifier.padding(Dimens.SpacingM), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
Text("Veranstalter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(event.vereinName, style = MaterialTheme.typography.bodyLarge)
Text("ID: ${event.veranstalterId}", style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { onNavigateToVeranstalterProfil(event.veranstalterId) }) {
Text("Vereinsprofil öffnen")
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
}
}
}
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f))) {
Column(Modifier.padding(Dimens.SpacingM), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
Text("Schnell-Aktionen", style = MaterialTheme.typography.labelLarge)
Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Ausschreibung (ZNS)") }
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Programmheft drucken") }
}
}
}
}
}
}
@Composable
private fun DetailKpiCard(label: String, wert: String, icon: ImageVector, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Row(
modifier = Modifier.padding(Dimens.SpacingS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp))
Column {
Text(label, style = MaterialTheme.typography.labelSmall)
Text(wert, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
} }
} }
} }

View File

@ -0,0 +1,69 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.core.domain.model.VeranstaltungsStatusE
import at.mocode.veranstaltung.feature.domain.model.VeranstaltungModel
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
data class VeranstaltungManagementState(
val veranstaltungen: List<VeranstaltungModel> = emptyList(),
val searchQuery: String = "",
val selectedStatus: VeranstaltungsStatusE? = null,
val isLoading: Boolean = false,
val error: String? = null
)
class VeranstaltungManagementViewModel(
private val repository: VeranstaltungRepository
) : ViewModel() {
var state by mutableStateOf(VeranstaltungManagementState())
private set
init {
loadVeranstaltungen()
}
private fun loadVeranstaltungen() {
viewModelScope.launch {
state = state.copy(isLoading = true)
repository.getAllVeranstaltungen().collectLatest { list ->
state = state.copy(
veranstaltungen = list,
isLoading = false
)
}
}
}
fun onSearchQueryChanged(query: String) {
state = state.copy(searchQuery = query)
}
fun onStatusFilterChanged(status: VeranstaltungsStatusE?) {
state = state.copy(selectedStatus = status)
}
val filteredVeranstaltungen: List<VeranstaltungModel>
get() {
return state.veranstaltungen.filter { event ->
val matchesSearch = event.titel.contains(state.searchQuery, ignoreCase = true) ||
event.vereinName.contains(state.searchQuery, ignoreCase = true)
val matchesStatus = state.selectedStatus == null || event.status == state.selectedStatus
matchesSearch && matchesStatus
}.sortedByDescending { it.datumVon }
}
fun deleteVeranstaltung(event: VeranstaltungModel) {
viewModelScope.launch {
repository.deleteVeranstaltung(event.veranstalterId, event.id)
loadVeranstaltungen() // Refresh
}
}
}

View File

@ -0,0 +1,455 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.*
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.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
) {
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()
)
}
}
) { 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
)
) {
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)
}
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,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (state.ansprechpersonName.isNotBlank()) {
Text(
text = "Ansprechperson: ${state.ansprechpersonName} (${state.ansprechpersonSatznummer})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@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)
if (!state.isZnsAvailable) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(12.dp))
Column {
Text("🚨 Stammdaten fehlen!", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
Text("Für die Anlage einer Veranstaltung werden Vereins- und Reitdaten benötigt. Bitte importieren Sie die aktuelle ZNS.zip (VEREIN01, LIZENZ01).")
}
}
}
Button(
onClick = { /* Navigiere zum ZNS Importer */ },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Icon(Icons.Default.CloudDownload, null)
Spacer(Modifier.width(8.dp))
Text("Zum ZNS-Importer")
}
OutlinedButton(
onClick = { viewModel.checkZnsAvailability() },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(8.dp))
Text("Status erneut prüfen")
}
} 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) {
var searchQuery by remember { mutableStateOf("") }
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).")
MsTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
if (it.length >= 3) {
viewModel.searchVeranstalterByOepsNr(it)
}
},
label = "Verein suchen (z.B. 6-009)",
placeholder = "OEPS-Nummer eingeben...",
modifier = Modifier.fillMaxWidth()
)
if (viewModel.state.veranstalterId != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.weight(1f)) {
Text(
viewModel.state.veranstalterName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text("OEPS-Nr: ${viewModel.state.veranstalterVereinsNummer}")
}
Button(onClick = { viewModel.nextStep() }) {
Text("Auswählen & Weiter")
}
}
}
} else {
// Information für den User
Text(
"Geben Sie mindestens 3 Zeichen der OEPS-Nummer ein, um die Stammdaten zu durchsuchen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Fallback/Demo Button beibehalten für 6-009
OutlinedButton(
onClick = { viewModel.searchVeranstalterByOepsNr("6-009") },
modifier = Modifier.fillMaxWidth()
) {
Text("Beispiel: Union Reit- u. Fahrverein Neumarkt/M. (6-009) suchen")
}
}
}
}
@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?")
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) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 4: Veranstaltungs-Parameter", style = MaterialTheme.typography.titleLarge)
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()
)
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(
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) {
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.")
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)
}
}
}
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) {
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), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Überprüfen Sie Ihre Angaben, bevor Sie die Veranstaltung final anlegen.",
style = MaterialTheme.typography.bodyMedium
)
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)
}
}

View File

@ -7,8 +7,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.core.domain.serialization.UuidSerializer import at.mocode.core.domain.serialization.UuidSerializer
import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.network.NetworkConfig import at.mocode.frontend.core.network.NetworkConfig
import at.mocode.frontend.features.verein.domain.VereinRepository
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
@ -19,40 +19,89 @@ import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
enum class WizardStep { enum class WizardStep {
ZNS_IMPORT, ZNS_CHECK,
VERANSTALTER_SELECTION,
ANSPRECHPERSON_MAPPING,
META_DATA, META_DATA,
TYPE_SELECTION TURNIER_ANLAGE,
SUMMARY
} }
@OptIn(ExperimentalUuidApi::class)
data class TurnierEntry(
val id: Uuid = Uuid.random(),
val nummer: String = "",
val ausschreibungPath: String? = null
)
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
data class VeranstaltungWizardState( data class VeranstaltungWizardState(
val currentStep: WizardStep = WizardStep.ZNS_IMPORT, val currentStep: WizardStep = WizardStep.ZNS_CHECK,
val veranstalterId: Uuid? = null, val veranstalterId: Uuid? = null,
val veranstalterVereinsNummer: String = "",
val veranstalterName: String = "",
val ansprechpersonSatznummer: String = "",
val ansprechpersonName: String = "",
val name: String = "", val name: String = "",
val ort: String = "", val ort: String = "",
val startDatum: LocalDate? = null, val startDatum: LocalDate? = null,
val endDatum: LocalDate? = null, val endDatum: LocalDate? = null,
val logoUrl: String? = null,
val turniere: List<TurnierEntry> = listOf(TurnierEntry()),
val isSaving: Boolean = false, val isSaving: Boolean = false,
val error: String? = null, val error: String? = null,
val createdVeranstaltungId: Uuid? = null val createdVeranstaltungId: Uuid? = null,
val isZnsAvailable: Boolean = false
) )
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
class VeranstaltungWizardViewModel( class VeranstaltungWizardViewModel(
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager, private val authTokenManager: AuthTokenManager,
val znsViewModel: ZnsImportProvider private val vereinRepository: VereinRepository
) : ViewModel() { ) : ViewModel() {
var state by mutableStateOf(VeranstaltungWizardState()) var state by mutableStateOf(VeranstaltungWizardState())
private set private set
init {
checkZnsAvailability()
// Simulation eines Initial-Datums
state = state.copy(startDatum = LocalDate(2026, 4, 25), endDatum = LocalDate(2026, 4, 26))
}
fun checkZnsAvailability() {
viewModelScope.launch {
val vereineResult = vereinRepository.getVereine()
val hasData = vereineResult.getOrNull()?.isNotEmpty() ?: false
state = state.copy(isZnsAvailable = hasData)
}
}
fun searchVeranstalterByOepsNr(oepsNr: String) {
viewModelScope.launch {
val verein = vereinRepository.findByOepsNr(oepsNr)
if (verein != null) {
setVeranstalter(
id = Uuid.parse(verein.id),
nummer = verein.oepsNr ?: "",
name = verein.name,
standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(),
logo = null // Hier könnte später ein Logo-Service greifen
)
}
}
}
fun nextStep() { fun nextStep() {
state = state.copy( state = state.copy(
currentStep = when (state.currentStep) { currentStep = when (state.currentStep) {
WizardStep.ZNS_IMPORT -> WizardStep.META_DATA WizardStep.ZNS_CHECK -> WizardStep.VERANSTALTER_SELECTION
WizardStep.META_DATA -> WizardStep.TYPE_SELECTION WizardStep.VERANSTALTER_SELECTION -> WizardStep.ANSPRECHPERSON_MAPPING
WizardStep.TYPE_SELECTION -> WizardStep.TYPE_SELECTION WizardStep.ANSPRECHPERSON_MAPPING -> WizardStep.META_DATA
WizardStep.META_DATA -> WizardStep.TURNIER_ANLAGE
WizardStep.TURNIER_ANLAGE -> WizardStep.SUMMARY
WizardStep.SUMMARY -> WizardStep.SUMMARY
} }
) )
} }
@ -60,19 +109,51 @@ class VeranstaltungWizardViewModel(
fun previousStep() { fun previousStep() {
state = state.copy( state = state.copy(
currentStep = when (state.currentStep) { currentStep = when (state.currentStep) {
WizardStep.ZNS_IMPORT -> WizardStep.ZNS_IMPORT WizardStep.ZNS_CHECK -> WizardStep.ZNS_CHECK
WizardStep.META_DATA -> WizardStep.ZNS_IMPORT WizardStep.VERANSTALTER_SELECTION -> WizardStep.ZNS_CHECK
WizardStep.TYPE_SELECTION -> WizardStep.META_DATA WizardStep.ANSPRECHPERSON_MAPPING -> WizardStep.VERANSTALTER_SELECTION
WizardStep.META_DATA -> WizardStep.ANSPRECHPERSON_MAPPING
WizardStep.TURNIER_ANLAGE -> WizardStep.META_DATA
WizardStep.SUMMARY -> WizardStep.TURNIER_ANLAGE
} }
) )
} }
fun updateMetaData(name: String, ort: String, start: LocalDate?, end: LocalDate?) { fun setVeranstalter(id: Uuid, nummer: String, name: String, standardOrt: String, logo: String?) {
state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end) state = state.copy(
veranstalterId = id,
veranstalterVereinsNummer = nummer,
veranstalterName = name,
ort = standardOrt,
logoUrl = logo
)
} }
fun setVeranstalter(id: Uuid) { fun setAnsprechperson(satznummer: String, name: String) {
state = state.copy(veranstalterId = id) state = state.copy(ansprechpersonSatznummer = satznummer, ansprechpersonName = name)
}
fun updateMetaData(name: String, ort: String, start: LocalDate?, end: LocalDate?, logo: String?) {
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() { fun saveVeranstaltung() {
@ -83,6 +164,9 @@ class VeranstaltungWizardViewModel(
viewModelScope.launch { viewModelScope.launch {
state = state.copy(isSaving = true, error = null) state = state.copy(isSaving = true, error = null)
try { try {
// PDF-Kopiervorgang (lokal) entfernt wegen Import-Problemen in dieser Umgebung
// TODO: File-Copy Logik in ein Platform-Service auslagern
val token = authTokenManager.authState.value.token val token = authTokenManager.authState.value.token
val response = httpClient.post("${NetworkConfig.baseUrl}/api/events") { val response = httpClient.post("${NetworkConfig.baseUrl}/api/events") {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token") if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
@ -99,7 +183,7 @@ class VeranstaltungWizardViewModel(
} }
if (response.status == HttpStatusCode.Created) { if (response.status == HttpStatusCode.Created) {
// Hier müsste die ID aus dem Response gelesen werden, falls benötigt // Hier müsste die ID aus der Response gelesen werden, falls benötigt
state = state.copy(isSaving = false) state = state.copy(isSaving = false)
nextStep() nextStep()
} else { } else {

View File

@ -1,69 +1,39 @@
package at.mocode.veranstaltung.feature.presentation package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.*
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.core.domain.model.VeranstaltungsStatusE
import at.mocode.frontend.core.designsystem.components.MsButton import at.mocode.frontend.core.designsystem.components.MsButton
import at.mocode.frontend.core.designsystem.components.MsCard import at.mocode.frontend.core.designsystem.components.MsCard
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.veranstaltung.feature.domain.model.VeranstaltungModel
import org.koin.compose.koinInject
import java.util.Locale.getDefault
/** /**
* UI-Modell für die Anzeige einer Veranstaltung in der Liste. * Veranstaltungs-Übersicht (Einstieg nach Onboarding gemäß ADR-0024).
*/ * Zeigt eine Liste aller Veranstaltungen mit Such- und Filterfunktion.
data class VeranstaltungSimpleUiModel(
val id: Long,
val name: String,
val untertitel: String?,
val ort: String,
val datum: String,
val logoUrl: String? = null
)
/**
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
*/ */
@Composable @Composable
fun VeranstaltungenScreen( fun VeranstaltungenScreen(
viewModel: VeranstaltungManagementViewModel = koinInject(),
onVeranstaltungNeu: () -> Unit, onVeranstaltungNeu: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit, onVeranstaltungOeffnen: (Long, Long) -> Unit,
) { ) {
// Später: Echte Daten aus dem ViewModel laden val state = viewModel.state
val veranstaltungen = remember { val filteredVeranstaltungen = viewModel.filteredVeranstaltungen
mutableStateListOf(
VeranstaltungSimpleUiModel(
id = 1L,
name = "Springturnier Neumarkt",
untertitel = "CSN-B* | 24. - 26. April 2026",
ort = "Neumarkt am Wallersee",
datum = "24.04.2026 - 26.04.2026"
),
VeranstaltungSimpleUiModel(
id = 2L,
name = "Dressurtage Lamprechtshausen",
untertitel = "CDN-A* | 01. - 03. Mai 2026",
ort = "Lamprechtshausen",
datum = "01.05.2026 - 03.05.2026"
)
)
}
Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) { Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) {
// Header: Titel + Action // Header: Titel + Action
@ -80,29 +50,91 @@ fun VeranstaltungenScreen(
MsButton( MsButton(
text = "Neue Veranstaltung", text = "Neue Veranstaltung",
onClick = onVeranstaltungNeu onClick = onVeranstaltungNeu
// icon = Icons.Default.Add // MsButton unterstützt noch kein Icon im Parameter
) )
} }
Spacer(Modifier.height(Dimens.SpacingL)) Spacer(Modifier.height(Dimens.SpacingL))
if (veranstaltungen.isEmpty()) { // Suche & Filter (Vision_03 High-Density)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = state.searchQuery,
onValueChange = { viewModel.onSearchQueryChanged(it) },
placeholder = { Text("Suche nach Titel oder Verein...", style = MaterialTheme.typography.bodyMedium) },
modifier = Modifier.weight(1f),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
trailingIcon = {
if (state.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.onSearchQueryChanged("") }) {
Icon(Icons.Default.Clear, contentDescription = "Löschen")
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium,
textStyle = MaterialTheme.typography.bodyMedium
)
// Status Filter Chips
Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = state.selectedStatus == null,
onClick = { viewModel.onStatusFilterChanged(null) },
label = { Text("Alle", style = MaterialTheme.typography.labelMedium) }
)
VeranstaltungsStatusE.entries.filter { it == VeranstaltungsStatusE.IN_PLANUNG || it == VeranstaltungsStatusE.ABGESCHLOSSEN || it == VeranstaltungsStatusE.AKTIV }
.forEach { status ->
FilterChip(
selected = state.selectedStatus == status,
onClick = { viewModel.onStatusFilterChanged(status) },
label = {
Text( Text(
"Keine Veranstaltungen gefunden. Legen Sie eine neue an.", status.name.lowercase()
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() },
style = MaterialTheme.typography.labelMedium
)
}
)
}
}
}
Spacer(Modifier.height(Dimens.SpacingL))
if (filteredVeranstaltungen.isEmpty() && !state.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.EventBusy,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = Color.LightGray
)
Spacer(Modifier.height(Dimens.SpacingM))
Text(
"Keine Veranstaltungen gefunden.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM) verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
contentPadding = PaddingValues(bottom = Dimens.SpacingL)
) { ) {
items(veranstaltungen) { event -> items(filteredVeranstaltungen) { event ->
VeranstaltungCard( VeranstaltungCard(
event = event, event = event,
onDoubleClick = { onVeranstaltungOeffnen(event.id) } onClick = { onVeranstaltungOeffnen(event.veranstalterId, event.id) }
) )
} }
} }
@ -110,37 +142,32 @@ fun VeranstaltungenScreen(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun VeranstaltungCard( fun VeranstaltungCard(
event: VeranstaltungSimpleUiModel, event: VeranstaltungModel,
onDoubleClick: () -> Unit onClick: () -> Unit
) { ) {
MsCard( MsCard(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() onClick = onClick
.combinedClickable(
onClick = { /* Einfacher Klick für Selektion, falls gewünscht */ },
onDoubleClick = onDoubleClick
)
) { ) {
Row( Row(
modifier = Modifier.padding(Dimens.SpacingS), modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Platzhalter für Logo // Logo / Icon
Box( Box(
modifier = Modifier modifier = Modifier
.size(64.dp) .size(48.dp)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Image( Icon(
imageVector = Icons.Default.Event, imageVector = Icons.Default.CalendarToday,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant) tint = MaterialTheme.colorScheme.primary
) )
} }
@ -148,24 +175,54 @@ fun VeranstaltungCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = event.name, text = event.titel,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
if (!event.untertitel.isNullOrBlank()) {
Text( Text(
text = event.untertitel, text = event.vereinName,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} Row(
Spacer(Modifier.height(4.dp)) verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
Icon(Icons.Default.Place, contentDescription = null, modifier = Modifier.size(14.dp), tint = Color.Gray)
Text(event.ort, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text("", color = Color.Gray)
Text( Text(
text = "${event.ort} | ${event.datum}", "${event.datumVon} - ${event.datumBis ?: ""}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface color = Color.Gray
) )
} }
} }
// Status Badge
Surface(
color = when (event.status) {
VeranstaltungsStatusE.ABGESCHLOSSEN -> Color(0xFFE8F5E9)
VeranstaltungsStatusE.IN_PLANUNG -> Color(0xFFE3F2FD)
else -> MaterialTheme.colorScheme.secondaryContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
text = event.status.name.lowercase()
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() },
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = when (event.status) {
VeranstaltungsStatusE.ABGESCHLOSSEN -> Color(0xFF2E7D32)
VeranstaltungsStatusE.IN_PLANUNG -> Color(0xFF1976D2)
else -> MaterialTheme.colorScheme.onSecondaryContainer
}
)
}
Spacer(Modifier.width(Dimens.SpacingM))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = Color.LightGray)
}
} }
} }

View File

@ -26,6 +26,10 @@ class FakeVereinRepository : VereinRepository {
override suspend fun getVereine(): Result<List<Verein>> = Result.success(vereine.toList()) override suspend fun getVereine(): Result<List<Verein>> = Result.success(vereine.toList())
override suspend fun findByOepsNr(oepsNr: String): Verein? {
return vereine.find { it.oepsNr == oepsNr }
}
override suspend fun saveVerein(verein: Verein): Result<Verein> { override suspend fun saveVerein(verein: Verein): Result<Verein> {
val index = vereine.indexOfFirst { it.id == verein.id } val index = vereine.indexOfFirst { it.id == verein.id }
if (index >= 0) { if (index >= 0) {

View File

@ -41,6 +41,17 @@ class KtorVereinRepository(
} else emptyList() } else emptyList()
} }
override suspend fun findByOepsNr(oepsNr: String): Verein? {
return runCatching {
val response = client.get("${ApiRoutes.Masterdata.VEREINE}/search") {
parameter("oepsNr", oepsNr)
}
if (response.status.isSuccess()) {
response.body<VereinDto>().toDomain()
} else null
}.getOrNull()
}
override suspend fun saveVerein(verein: Verein): Result<Verein> = runCatching { override suspend fun saveVerein(verein: Verein): Result<Verein> = runCatching {
if (verein.id.isBlank() || verein.id.startsWith("new_")) { if (verein.id.isBlank() || verein.id.startsWith("new_")) {
val request = VereinCreateRequest( val request = VereinCreateRequest(

View File

@ -3,4 +3,5 @@ package at.mocode.frontend.features.verein.domain
interface VereinRepository { interface VereinRepository {
suspend fun getVereine(): Result<List<Verein>> suspend fun getVereine(): Result<List<Verein>>
suspend fun saveVerein(verein: Verein): Result<Verein> suspend fun saveVerein(verein: Verein): Result<Verein>
suspend fun findByOepsNr(oepsNr: String): Verein?
} }

View File

@ -45,6 +45,7 @@ kotlin {
jvmMain.dependencies { jvmMain.dependencies {
// Core-Module // Core-Module
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
implementation(projects.core.coreDomain)
implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.navigation) implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.network) implementation(projects.frontend.core.network)

View File

@ -1,12 +0,0 @@
{
"deviceName": "Meldestelle",
"sharedKey": "Password",
"backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
}
]
}

View File

@ -0,0 +1,59 @@
package at.mocode.frontend.shell.desktop.data.repository
import at.mocode.core.domain.model.VeranstaltungsStatusE
import at.mocode.frontend.shell.desktop.data.Store
import at.mocode.veranstaltung.feature.domain.model.VeranstaltungModel
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDate
/**
* Brücken-Implementierung, die den bestehenden Store nutzt.
* Liegt in der Shell, da sie Zugriff auf den Shell-spezifischen [Store] benötigt.
*/
class StoreVeranstaltungRepository : VeranstaltungRepository {
override fun getAllVeranstaltungen(): Flow<List<VeranstaltungModel>> = flow {
val allEvents = Store.allEvents().map { event ->
val verein = Store.vereine.find { it.id == event.veranstalterId }
VeranstaltungModel(
id = event.id,
veranstalterId = event.veranstalterId,
titel = event.titel,
datumVon = try {
LocalDate.parse(event.datumVon)
} catch (_: Exception) {
LocalDate(2026, 4, 20)
},
datumBis = try {
event.datumBis?.let { LocalDate.parse(it) }
} catch (_: Exception) {
null
},
status = mapStatus(event.status),
ort = event.ort,
vereinName = verein?.name ?: "Unbekannter Verein",
logoUrl = event.logoUrl
)
}
emit(allEvents)
}
override fun getVeranstaltungenByStatus(status: String): Flow<List<VeranstaltungModel>> = flow {
// Aktuell filtern wir noch nicht tief im Store, das Dashboard übernimmt das Filtern des Flows
emit(emptyList())
}
override suspend fun deleteVeranstaltung(veranstalterId: Long, veranstaltungId: Long) {
Store.removeEvent(veranstalterId, veranstaltungId)
}
private fun mapStatus(status: String): VeranstaltungsStatusE {
return when (status) {
"Abgeschlossen" -> VeranstaltungsStatusE.ABGESCHLOSSEN
"In Vorbereitung" -> VeranstaltungsStatusE.IN_PLANUNG
else -> VeranstaltungsStatusE.AKTIV
}
}
}

View File

@ -15,13 +15,17 @@ import at.mocode.frontend.features.device.initialization.di.deviceInitialization
import at.mocode.frontend.features.funktionaer.di.funktionaerModule import at.mocode.frontend.features.funktionaer.di.funktionaerModule
import at.mocode.frontend.features.nennung.di.nennungFeatureModule import at.mocode.frontend.features.nennung.di.nennungFeatureModule
import at.mocode.frontend.features.pferde.di.pferdeModule import at.mocode.frontend.features.pferde.di.pferdeModule
import at.mocode.frontend.features.ping.di.pingFeatureModule
import at.mocode.frontend.features.profile.di.profileModule import at.mocode.frontend.features.profile.di.profileModule
import at.mocode.frontend.features.reiter.di.reiterModule import at.mocode.frontend.features.reiter.di.reiterModule
import at.mocode.frontend.features.turnier.di.turnierFeatureModule
import at.mocode.frontend.features.veranstalter.di.veranstalterModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.frontend.features.zns.import.di.znsImportModule import at.mocode.frontend.features.zns.import.di.znsImportModule
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
import at.mocode.frontend.shell.desktop.di.desktopModule import at.mocode.frontend.shell.desktop.di.desktopModule
import at.mocode.frontend.features.ping.di.pingFeatureModule import at.mocode.veranstaltung.feature.di.veranstaltungModule
import at.mocode.frontend.features.turnier.di.turnierFeatureModule import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules import org.koin.core.context.loadKoinModules
@ -45,7 +49,12 @@ fun main() = application {
reiterModule, reiterModule,
funktionaerModule, funktionaerModule,
vereinFeatureModule, vereinFeatureModule,
veranstalterModule,
turnierFeatureModule, turnierFeatureModule,
veranstaltungModule,
module {
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
},
deviceInitializationModule, deviceInitializationModule,
desktopModule, desktopModule,
) )

View File

@ -70,6 +70,7 @@ fun DesktopMainLayout(
onBack = onBack, onBack = onBack,
onLogout = onLogout, onLogout = onLogout,
isAuthenticated = isAuthenticated, isAuthenticated = isAuthenticated,
isConfigured = onboardingSettings.isConfigured,
connectedPeersCount = connectedPeers.size connectedPeersCount = connectedPeers.size
) )

View File

@ -40,13 +40,13 @@ import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.VeranstaltungVerwaltung
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungenScreen
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -76,18 +76,9 @@ fun DesktopContentArea(
// Haupt-Zentrale: Veranstaltung-Verwaltung // Haupt-Zentrale: Veranstaltung-Verwaltung
is AppScreen.VeranstaltungVerwaltung -> { is AppScreen.VeranstaltungVerwaltung -> {
VeranstaltungVerwaltung( VeranstaltungenScreen(
onVeranstaltungOpen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) }, onVeranstaltungNeu = { onNavigate(AppScreen.VeranstalterAuswahl) },
onNewVeranstaltung = { onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) }
// Wenn wir direkt aus der Übersicht kommen, erst Veranstalter wählen lassen
onNavigate(AppScreen.VeranstalterAuswahl)
},
onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) },
onNavigateToReiter = { onNavigate(AppScreen.ReiterVerwaltung) },
onNavigateToVereine = { onNavigate(AppScreen.VereinVerwaltung) },
onNavigateToFunktionaere = { onNavigate(AppScreen.FunktionaerVerwaltung) },
onNavigateToVeranstalter = { onNavigate(AppScreen.VeranstalterVerwaltung) },
onNavigateToZnsImport = { onNavigate(AppScreen.StammdatenImport) }
) )
} }
@ -173,13 +164,13 @@ fun DesktopContentArea(
veranstalterId = currentScreen.id, veranstalterId = currentScreen.id,
onBack = onBack, onBack = onBack,
onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.VeranstaltungProfil(currentScreen.id, evtId)) }, onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.VeranstaltungProfil(currentScreen.id, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(currentScreen.id)) }, onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungNeu) },
) )
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht // Neuer Flow: Veranstalter auswählen → Veranstaltung-Wizard
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl( is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl(
onBack = onBack, onBack = onBack,
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) }, onWeiter = { _ -> onNavigate(AppScreen.VeranstaltungNeu) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
) )
@ -231,18 +222,23 @@ fun DesktopContentArea(
} }
is AppScreen.VeranstaltungDetail -> { is AppScreen.VeranstaltungDetail -> {
val repository: at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository = koinInject()
VeranstaltungDetailScreen( VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id, veranstaltungId = currentScreen.id,
repository = repository,
onBack = onBack, onBack = onBack,
onTurnierOeffnen = { tId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tId)) }, onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tId)) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) } onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) },
onNavigateToVeranstalterProfil = { verId -> onNavigate(AppScreen.VeranstalterDetail(verId)) }
) )
} }
is AppScreen.VeranstaltungNeu -> { is AppScreen.VeranstaltungNeu -> {
VeranstaltungNeuScreen( val viewModel: at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel = koinViewModel()
at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardScreen(
viewModel = viewModel,
onBack = onBack, onBack = onBack,
onSave = { onBack() } onFinish = { onBack() }
) )
} }
@ -261,11 +257,11 @@ fun DesktopContentArea(
at.mocode.frontend.shell.desktop.data.Store.eventsFor(parent.id).firstOrNull { it.id == evtId } at.mocode.frontend.shell.desktop.data.Store.eventsFor(parent.id).firstOrNull { it.id == evtId }
// bewerbViewModel: BewerbViewModel, nennungViewModel: TurnierNennungViewModel, stammdatenViewModel: TurnierStammdatenViewModel // bewerbViewModel: BewerbViewModel, nennungViewModel: TurnierNennungViewModel, stammdatenViewModel: TurnierStammdatenViewModel
val bewerbViewModel: at.mocode.frontend.features.turnier.presentation.BewerbViewModel = val bewerbViewModel: at.mocode.frontend.features.turnier.presentation.BewerbViewModel =
org.koin.compose.koinInject { parametersOf(currentScreen.turnierId) } koinInject { parametersOf(currentScreen.turnierId) }
val nennungViewModel: at.mocode.frontend.features.turnier.presentation.TurnierNennungViewModel = val nennungViewModel: at.mocode.frontend.features.turnier.presentation.TurnierNennungViewModel =
org.koin.compose.koinInject { parametersOf(currentScreen.turnierId) } koinInject { parametersOf(currentScreen.turnierId) }
val stammdatenViewModel: at.mocode.frontend.features.turnier.presentation.TurnierStammdatenViewModel = val stammdatenViewModel: at.mocode.frontend.features.turnier.presentation.TurnierStammdatenViewModel =
org.koin.compose.koinInject() koinInject()
TurnierDetailScreen( TurnierDetailScreen(
veranstaltungId = evtId, veranstaltungId = evtId,

View File

@ -36,7 +36,7 @@ fun DesktopNavRail(
label = "Logo", label = "Logo",
selected = false, selected = false,
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
enabled = true enabled = isConfigured
) )
Spacer(Modifier.height(Dimens.SpacingL)) Spacer(Modifier.height(Dimens.SpacingL))

View File

@ -28,6 +28,7 @@ fun DesktopTopHeader(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
isAuthenticated: Boolean, isAuthenticated: Boolean,
isConfigured: Boolean = true,
connectedPeersCount: Int = 0 connectedPeersCount: Int = 0
) { ) {
Surface( Surface(
@ -41,9 +42,15 @@ fun DesktopTopHeader(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (currentScreen !is AppScreen.DeviceInitialization) { // Zurück-Button ausblenden auf Startseite oder im Setup
if (currentScreen !is AppScreen.DeviceInitialization && currentScreen !is AppScreen.VeranstaltungVerwaltung) {
IconButton( IconButton(
onClick = onBack, onClick = {
// Verhindere Rücksprung zum Setup, wenn konfiguriert
if (currentScreen !is AppScreen.DeviceInitialization || !isConfigured) {
onBack()
}
},
modifier = Modifier.size(Dimens.IconSizeM) modifier = Modifier.size(Dimens.IconSizeM)
) { ) {
Icon( Icon(
@ -59,13 +66,16 @@ fun DesktopTopHeader(
// Home Icon als Anker // Home Icon als Anker
IconButton( IconButton(
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
modifier = Modifier.size(Dimens.IconSizeM) modifier = Modifier.size(Dimens.IconSizeM),
enabled = isConfigured
) { ) {
Icon( Icon(
imageVector = Icons.Default.Home, imageVector = Icons.Default.Home,
contentDescription = "Home", contentDescription = "Home",
modifier = Modifier.size(Dimens.IconSizeM), modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.primary tint = if (isConfigured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.38f
)
) )
} }

View File

@ -1,195 +0,0 @@
package at.mocode.frontend.shell.desktop.screens.veranstaltung
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.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.unit.dp
import at.mocode.frontend.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.theme.DesktopTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VeranstaltungVerwaltung(
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewVeranstaltung: () -> Unit,
onNavigateToPferde: () -> Unit,
onNavigateToReiter: () -> Unit,
onNavigateToVereine: () -> Unit,
onNavigateToFunktionaere: () -> Unit,
onNavigateToVeranstalter: () -> Unit,
onNavigateToZnsImport: () -> Unit
) {
LaunchedEffect(Unit) { println("[Screen] VeranstaltungVerwaltung geladen") }
DesktopTheme {
val allVeranstaltungen = remember { Store.allEvents() }
val vereine = Store.vereine
var searchQuery by remember { mutableStateOf("") }
var selectedStatus by remember { mutableStateOf<String?>(null) }
val availableStatuses = remember(allVeranstaltungen) { allVeranstaltungen.map { it.status }.distinct().sorted() }
val filteredVeranstaltungen = remember(allVeranstaltungen, searchQuery, selectedStatus) {
allVeranstaltungen.filter { veranstaltung ->
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
val matchesSearch = veranstaltung.titel.contains(searchQuery, ignoreCase = true) ||
(verein?.name?.contains(searchQuery, ignoreCase = true) ?: false)
val matchesStatus = selectedStatus == null || veranstaltung.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,
verticalAlignment = Alignment.CenterVertically
) {
Text("Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium)
Button(onClick = onNewVeranstaltung) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
}
}
// 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 = status },
label = { Text(status) }
)
}
}
}
}
}
// Liste
if (filteredVeranstaltungen.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.EventBusy, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.LightGray)
Spacer(Modifier.height(16.dp))
Text("Keine Veranstaltungen gefunden", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
}
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxSize()) {
items(filteredVeranstaltungen) { veranstaltung ->
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
VeranstaltungCard(
veranstaltung = veranstaltung,
vereinName = verein?.name ?: "Unbekannter Verein",
onClick = { onVeranstaltungOpen(veranstaltung.veranstalterId, veranstaltung.id) }
)
}
}
}
}
}
}
@Composable
fun VeranstaltungCard(
veranstaltung: at.mocode.frontend.shell.desktop.data.Veranstaltung,
vereinName: String,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.medium
) {
Icon(
Icons.Default.CalendarToday,
contentDescription = null,
modifier = Modifier.padding(12.dp).size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Column(Modifier.weight(1f)) {
Text(veranstaltung.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(vereinName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.Place, contentDescription = null, modifier = Modifier.size(14.dp), tint = Color.Gray)
Text(veranstaltung.ort, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text("", color = Color.Gray)
Text("${veranstaltung.datumVon} - ${veranstaltung.datumBis ?: ""}", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
Surface(
color = when (veranstaltung.status) {
"Abgeschlossen" -> Color(0xFFE8F5E9)
"In Vorbereitung" -> Color(0xFFE3F2FD)
else -> MaterialTheme.colorScheme.secondaryContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
veranstaltung.status,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = when (veranstaltung.status) {
"Abgeschlossen" -> Color(0xFF2E7D32)
"In Vorbereitung" -> Color(0xFF1976D2)
else -> MaterialTheme.colorScheme.onSecondaryContainer
}
)
}
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = Color.LightGray)
}
}
}