diff --git a/docs/04_Agents/Journal/2026-04-20_Session-Log_Onboarding.md b/docs/04_Agents/Journal/2026-04-20_Session-Log_Onboarding.md index 69aed22b..80c0f58a 100644 --- a/docs/04_Agents/Journal/2026-04-20_Session-Log_Onboarding.md +++ b/docs/04_Agents/Journal/2026-04-20_Session-Log_Onboarding.md @@ -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. - **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) - **Self-Contained:** Feature-Module verwalten ihren State; Shell reagiert auf Events. - **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.* + +### 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. diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsCard.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsCard.kt index 80be1f74..05f40c65 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsCard.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsCard.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -23,35 +22,42 @@ import at.mocode.frontend.core.designsystem.theme.Dimens @Composable fun MsCard( modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit ) { - Card( - 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 + 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 ) { - content() + Column( + modifier = Modifier.padding(Dimens.SpacingS) // Kompaktes Padding innen + ) { + 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) + } else { + Card( + 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() } } } } + diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt new file mode 100644 index 00000000..91d12a22 --- /dev/null +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt @@ -0,0 +1,8 @@ +package at.mocode.veranstaltung.feature.di + +import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel +import org.koin.dsl.module + +val veranstaltungModule = module { + factory { VeranstaltungManagementViewModel(get()) } +} diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/domain/model/VeranstaltungModel.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/domain/model/VeranstaltungModel.kt new file mode 100644 index 00000000..42f88b97 --- /dev/null +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/domain/model/VeranstaltungModel.kt @@ -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 +) diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/domain/repository/VeranstaltungRepository.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/domain/repository/VeranstaltungRepository.kt new file mode 100644 index 00000000..f28dced5 --- /dev/null +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/domain/repository/VeranstaltungRepository.kt @@ -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> + fun getVeranstaltungenByStatus(status: String): Flow> + suspend fun deleteVeranstaltung(veranstalterId: Long, veranstaltungId: Long) +} diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt index b8351068..7977763b 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt @@ -3,65 +3,150 @@ package at.mocode.veranstaltung.feature.presentation import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew 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.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment 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 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 fun VeranstaltungDetailScreen( - veranstaltungId: Long, - onBack: () -> Unit, - onTurnierNeu: () -> Unit, - onTurnierOeffnen: (Long) -> Unit, + veranstaltungId: Long, + repository: VeranstaltungRepository, + onBack: () -> Unit, + onTurnierNeu: () -> Unit, + onTurnierOpen: (Long) -> Unit, + onNavigateToVeranstalterProfil: (Long) -> Unit ) { - Column(modifier = Modifier.fillMaxSize()) { - // Toolbar - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") - } - Spacer(Modifier.width(8.dp)) - Text( - text = "Veranstaltung #$veranstaltungId", - style = MaterialTheme.typography.headlineSmall, - ) + var veranstaltung by remember { mutableStateOf(null) } + 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 } - PrimaryTabRow(selectedTabIndex = 0) { - Tab(selected = true, onClick = {}, text = { Text("Veranstaltung – Übersicht") }) - } - - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - // Turniere-Section - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Turniere", style = MaterialTheme.typography.titleMedium) - OutlinedButton(onClick = onTurnierNeu) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Neues Turnier") + 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( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS) + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + Column { + Text( + text = event.titel, + 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)) + Button(onClick = onTurnierNeu) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(Dimens.SpacingXS)) + Text("Turnier hinzufügen") + } + } + + Spacer(Modifier.height(Dimens.SpacingL)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + // Linke Spalte: Details & Turniere + Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + // KPIs + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) { + DetailKpiCard("Status", event.status.name, Icons.Default.Info, Modifier.weight(1f)) + DetailKpiCard("Ort", event.ort, Icons.Default.Place, Modifier.weight(1f)) + } + + Text( + text = "Turniere in dieser Veranstaltung", + style = MaterialTheme.typography.titleLarge, + 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) + } } - } - Spacer(Modifier.height(16.dp)) - PlaceholderContent( - title = "Noch keine Turniere", - subtitle = "Lege ein neues Turnier für diese Veranstaltung an.", - ) } - } } diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungManagementViewModel.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungManagementViewModel.kt new file mode 100644 index 00000000..56421573 --- /dev/null +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungManagementViewModel.kt @@ -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 = 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 + 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 + } + } +} diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt index d1f276d8..ea63dc05 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt @@ -1,69 +1,39 @@ 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.combinedClickable 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.Event -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.unit.dp +import at.mocode.core.domain.model.VeranstaltungsStatusE import at.mocode.frontend.core.designsystem.components.MsButton import at.mocode.frontend.core.designsystem.components.MsCard 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. - */ -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". + * Veranstaltungs-Übersicht (Einstieg nach Onboarding gemäß ADR-0024). + * Zeigt eine Liste aller Veranstaltungen mit Such- und Filterfunktion. */ @Composable fun VeranstaltungenScreen( + viewModel: VeranstaltungManagementViewModel = koinInject(), onVeranstaltungNeu: () -> Unit, - onVeranstaltungOeffnen: (Long) -> Unit, + onVeranstaltungOeffnen: (Long, Long) -> Unit, ) { - // Später: Echte Daten aus dem ViewModel laden - val veranstaltungen = remember { - 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" - ) - ) - } + val state = viewModel.state + val filteredVeranstaltungen = viewModel.filteredVeranstaltungen Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) { // Header: Titel + Action @@ -80,29 +50,91 @@ fun VeranstaltungenScreen( MsButton( text = "Neue Veranstaltung", onClick = onVeranstaltungNeu - // icon = Icons.Default.Add // MsButton unterstützt noch kein Icon im Parameter ) } Spacer(Modifier.height(Dimens.SpacingL)) - if (veranstaltungen.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - "Keine Veranstaltungen gefunden. Legen Sie eine neue an.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + // Suche & Filter (Vision_03 High-Density) + 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( + 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, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } else { LazyColumn( 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( event = event, - onDoubleClick = { onVeranstaltungOeffnen(event.id) } + onClick = { onVeranstaltungOeffnen(event.veranstalterId, event.id) } ) } } @@ -110,37 +142,32 @@ fun VeranstaltungenScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun VeranstaltungCard( - event: VeranstaltungSimpleUiModel, - onDoubleClick: () -> Unit + event: VeranstaltungModel, + onClick: () -> Unit ) { MsCard( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { /* Einfacher Klick für Selektion, falls gewünscht */ }, - onDoubleClick = onDoubleClick - ) + modifier = Modifier.fillMaxWidth(), + onClick = onClick ) { Row( - modifier = Modifier.padding(Dimens.SpacingS), + modifier = Modifier.padding(Dimens.SpacingM), verticalAlignment = Alignment.CenterVertically ) { - // Platzhalter für Logo + // Logo / Icon Box( modifier = Modifier - .size(64.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), + .size(48.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)), contentAlignment = Alignment.Center ) { - Image( - imageVector = Icons.Default.Event, + Icon( + imageVector = Icons.Default.CalendarToday, contentDescription = null, - modifier = Modifier.size(32.dp), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant) + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary ) } @@ -148,24 +175,54 @@ fun VeranstaltungCard( Column(modifier = Modifier.weight(1f)) { Text( - text = event.name, + text = event.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) - if (!event.untertitel.isNullOrBlank()) { + Text( + text = event.vereinName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + 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 = event.untertitel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + "${event.datumVon} - ${event.datumBis ?: ""}", + style = MaterialTheme.typography.labelSmall, + color = Color.Gray ) } - Spacer(Modifier.height(4.dp)) + } + + // 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.ort} | ${event.datum}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + 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) } } } diff --git a/frontend/shells/meldestelle-desktop/build.gradle.kts b/frontend/shells/meldestelle-desktop/build.gradle.kts index b012900b..dcebf78f 100644 --- a/frontend/shells/meldestelle-desktop/build.gradle.kts +++ b/frontend/shells/meldestelle-desktop/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { jvmMain.dependencies { // Core-Module implementation(projects.frontend.core.domain) + implementation(projects.core.coreDomain) implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.navigation) implementation(projects.frontend.core.network) diff --git a/frontend/shells/meldestelle-desktop/settings.json b/frontend/shells/meldestelle-desktop/settings.json deleted file mode 100644 index 2fd53b82..00000000 --- a/frontend/shells/meldestelle-desktop/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "deviceName": "Meldestelle", - "sharedKey": "Password", - "backupPath": "/mocode/meldestelle/docs/temp", - "networkRole": "MASTER", - "expectedClients": [ - { - "name": "Richter-Turm", - "role": "RICHTER" - } - ] -} \ No newline at end of file diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/data/repository/StoreVeranstaltungRepository.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/data/repository/StoreVeranstaltungRepository.kt new file mode 100644 index 00000000..baf89d6b --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/data/repository/StoreVeranstaltungRepository.kt @@ -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> = 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> = 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 + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt index 01a8e6d1..bda9c5d8 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/main.kt @@ -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.nennung.di.nennungFeatureModule 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.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.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.features.ping.di.pingFeatureModule -import at.mocode.frontend.features.turnier.di.turnierFeatureModule +import at.mocode.veranstaltung.feature.di.veranstaltungModule +import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository import kotlinx.coroutines.runBlocking import org.koin.core.context.GlobalContext import org.koin.core.context.loadKoinModules @@ -45,7 +49,12 @@ fun main() = application { reiterModule, funktionaerModule, vereinFeatureModule, + veranstalterModule, turnierFeatureModule, + veranstaltungModule, + module { + single { StoreVeranstaltungRepository() } + }, deviceInitializationModule, desktopModule, ) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt index 4993e973..f9fd1d6d 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt @@ -70,6 +70,7 @@ fun DesktopMainLayout( onBack = onBack, onLogout = onLogout, isAuthenticated = isAuthenticated, + isConfigured = onboardingSettings.isConfigured, connectedPeersCount = connectedPeers.size ) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt index ef56862c..d26bc6e4 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt @@ -40,13 +40,14 @@ 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.VeranstalterVerwaltungScreen 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.wizards.TurnierWizard import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen 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.core.parameter.parametersOf @@ -76,18 +77,9 @@ fun DesktopContentArea( // Haupt-Zentrale: Veranstaltung-Verwaltung is AppScreen.VeranstaltungVerwaltung -> { - VeranstaltungVerwaltung( - onVeranstaltungOpen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) }, - onNewVeranstaltung = { - // 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) } + VeranstaltungenScreen( + onVeranstaltungNeu = { onNavigate(AppScreen.VeranstalterAuswahl) }, + onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) } ) } @@ -231,11 +223,14 @@ fun DesktopContentArea( } is AppScreen.VeranstaltungDetail -> { + val repository: at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository = koinInject() VeranstaltungDetailScreen( veranstaltungId = currentScreen.id, + repository = repository, onBack = onBack, - onTurnierOeffnen = { tId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tId)) }, - onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) } + onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tId)) }, + onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) }, + onNavigateToVeranstalterProfil = { verId -> onNavigate(AppScreen.VeranstalterDetail(verId)) } ) } @@ -261,11 +256,11 @@ fun DesktopContentArea( at.mocode.frontend.shell.desktop.data.Store.eventsFor(parent.id).firstOrNull { it.id == evtId } // bewerbViewModel: BewerbViewModel, nennungViewModel: TurnierNennungViewModel, stammdatenViewModel: TurnierStammdatenViewModel 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 = - org.koin.compose.koinInject { parametersOf(currentScreen.turnierId) } + koinInject { parametersOf(currentScreen.turnierId) } val stammdatenViewModel: at.mocode.frontend.features.turnier.presentation.TurnierStammdatenViewModel = - org.koin.compose.koinInject() + koinInject() TurnierDetailScreen( veranstaltungId = evtId, diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt index a29403c1..996b11e3 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt @@ -36,7 +36,7 @@ fun DesktopNavRail( label = "Logo", selected = false, onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, - enabled = true + enabled = isConfigured ) Spacer(Modifier.height(Dimens.SpacingL)) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt index ebfb59f0..3b6ef382 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt @@ -28,6 +28,7 @@ fun DesktopTopHeader( onBack: () -> Unit, onLogout: () -> Unit, isAuthenticated: Boolean, + isConfigured: Boolean = true, connectedPeersCount: Int = 0 ) { Surface( @@ -59,13 +60,16 @@ fun DesktopTopHeader( // Home Icon als Anker IconButton( onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, - modifier = Modifier.size(Dimens.IconSizeM) + modifier = Modifier.size(Dimens.IconSizeM), + enabled = isConfigured ) { Icon( imageVector = Icons.Default.Home, contentDescription = "Home", modifier = Modifier.size(Dimens.IconSizeM), - tint = MaterialTheme.colorScheme.primary + tint = if (isConfigured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.38f + ) ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/VeranstaltungVerwaltung.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/VeranstaltungVerwaltung.kt deleted file mode 100644 index 4b07e798..00000000 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/VeranstaltungVerwaltung.kt +++ /dev/null @@ -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(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) - } - } -}