diff --git a/docs/06_Frontend/FIGMA/Vision_02/guidelines/Guidelines.md b/docs/06_Frontend/FIGMA/Vision_02/guidelines/Guidelines.md deleted file mode 100644 index 110f1178..00000000 --- a/docs/06_Frontend/FIGMA/Vision_02/guidelines/Guidelines.md +++ /dev/null @@ -1,61 +0,0 @@ -**Add your own guidelines here** - diff --git a/docs/06_Frontend/FIGMA/Vision_03/guidelines/Guidelines.md b/docs/06_Frontend/FIGMA/Vision_03/guidelines/Guidelines.md deleted file mode 100644 index 110f1178..00000000 --- a/docs/06_Frontend/FIGMA/Vision_03/guidelines/Guidelines.md +++ /dev/null @@ -1,61 +0,0 @@ -**Add your own guidelines here** - diff --git a/docs/06_Frontend/FIGMA/Vison_01/guidelines/Guidelines.md b/docs/06_Frontend/FIGMA/Vison_01/guidelines/Guidelines.md deleted file mode 100644 index 110f1178..00000000 --- a/docs/06_Frontend/FIGMA/Vison_01/guidelines/Guidelines.md +++ /dev/null @@ -1,61 +0,0 @@ -**Add your own guidelines here** - diff --git a/docs/06_Frontend/Screenshots/Veranstaltung-Konfig_entwurf-01.png b/docs/06_Frontend/Screenshots/Veranstaltung-Konfig_entwurf-01.png new file mode 100644 index 00000000..72ad6d9f Binary files /dev/null and b/docs/06_Frontend/Screenshots/Veranstaltung-Konfig_entwurf-01.png differ diff --git a/docs/06_Frontend/Screenshots/Veranstaltungen-Status-Anzeige_entwurf-01.png b/docs/06_Frontend/Screenshots/Veranstaltungen-Status-Anzeige_entwurf-01.png new file mode 100644 index 00000000..98a182da Binary files /dev/null and b/docs/06_Frontend/Screenshots/Veranstaltungen-Status-Anzeige_entwurf-01.png differ diff --git a/docs/06_Frontend/Screenshots/Veranstaltungen-VeranstaltungsCard_entwurf-01.png b/docs/06_Frontend/Screenshots/Veranstaltungen-VeranstaltungsCard_entwurf-01.png new file mode 100644 index 00000000..e693057b Binary files /dev/null and b/docs/06_Frontend/Screenshots/Veranstaltungen-VeranstaltungsCard_entwurf-01.png differ diff --git a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt index 6a85d949..3a77f200 100644 --- a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt +++ b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt @@ -1,6 +1,8 @@ package at.mocode.frontend.core.navigation sealed class AppScreen(val route: String) { + // Onboarding (Desktop: Gerätename/Schlüssel/ZNS) + data object Onboarding : AppScreen("/onboarding") data object Landing : AppScreen(Routes.HOME) data object Home : AppScreen("/home") data object Dashboard : AppScreen("/dashboard") @@ -22,6 +24,8 @@ sealed class AppScreen(val route: String) { data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl") data object VeranstalterNeu : AppScreen("/veranstalter/neu") data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId") + // Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail → "+ Neue Veranstaltung") + data class VeranstaltungKonfig(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu") data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) : AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId") @@ -43,10 +47,12 @@ sealed class AppScreen(val route: String) { private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$") private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$") private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$") + private val VERANSTALTUNG_KONFIG = Regex("/veranstalter/(\\d+)/veranstaltung/neu$") private val VERANSTALTUNG_UEBERSICHT = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$") fun fromRoute(route: String): AppScreen { return when (route) { + "/onboarding" -> Onboarding Routes.HOME -> Landing "/home" -> Home "/dashboard" -> Dashboard @@ -79,6 +85,9 @@ sealed class AppScreen(val route: String) { VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) -> return VeranstalterDetail(vId.toLong()) } + VERANSTALTUNG_KONFIG.matchEntire(route)?.destructured?.let { (vId) -> + return VeranstaltungKonfig(vId.toLong()) + } VERANSTALTUNG_UEBERSICHT.matchEntire(route)?.destructured?.let { (verId, vId) -> return VeranstaltungUebersicht(verId.toLong(), vId.toLong()) } diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/FakeVeranstalterStore.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/FakeVeranstalterStore.kt new file mode 100644 index 00000000..7cf4f9c6 --- /dev/null +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/FakeVeranstalterStore.kt @@ -0,0 +1,35 @@ +package at.mocode.veranstalter.feature.presentation + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import at.mocode.frontend.core.designsystem.models.LoginStatus + +/** + * Einfacher In-Memory-Store für Veranstalter (Vereine) zur Navigation/Validierung im Prototyp. + */ +object FakeVeranstalterStore { + private val list: SnapshotStateList = mutableStateListOf( + VeranstalterUiModel( + id = 1, + name = "Reitverein Neumarkt", + oepsNummer = "OEPS-12345", + ort = "4221 Neumarkt/M.", + ansprechpartner = "Max Mustermann", + email = "info@rv-neumarkt.at", + loginStatus = LoginStatus.AKTIV + ), + VeranstalterUiModel( + id = 2, + name = "Union Reitclub Oberösterreich", + oepsNummer = "OEPS-67890", + ort = "4020 Linz", + ansprechpartner = "Anna Beispiel", + email = "kontakt@ur-ooe.at", + loginStatus = LoginStatus.AKTIV + ) + ) + + fun all(): SnapshotStateList = list + + fun exists(id: Long): Boolean = list.any { it.id == id } +} diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/FakeVeranstaltungStore.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/FakeVeranstaltungStore.kt new file mode 100644 index 00000000..4fa22055 --- /dev/null +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/FakeVeranstaltungStore.kt @@ -0,0 +1,37 @@ +package at.mocode.veranstalter.feature.presentation + +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.mutableStateListOf + +/** + * Einfacher In-Memory-Store für Veranstaltungen pro Veranstalter. + * Dient dem Prototyping ohne Backend/DB. + */ +object FakeVeranstaltungStore { + private val data: MutableMap> = mutableMapOf() + + fun listFor(veranstalterId: Long): SnapshotStateList = + data.getOrPut(veranstalterId) { mutableStateListOf() } + + fun addFirst(veranstalterId: Long, item: VeranstaltungListUiModel) { + val list = listFor(veranstalterId) + list.add(0, item) + } + + fun seedIfEmpty(veranstalterId: Long, items: List) { + val list = listFor(veranstalterId) + if (list.isEmpty()) list.addAll(items) + } + + fun exists(veranstaltungId: Long): Boolean = + data.values.any { list -> list.any { it.id == veranstaltungId } } + + fun belongsTo(veranstalterId: Long, veranstaltungId: Long): Boolean = + data[veranstalterId]?.any { it.id == veranstaltungId } ?: false + + fun remove(veranstalterId: Long, veranstaltungId: Long) { + val list = data[veranstalterId] ?: return + val idx = list.indexOfFirst { it.id == veranstaltungId } + if (idx >= 0) list.removeAt(idx) + } +} diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterDetailScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterDetailScreen.kt index 42bcf8bf..3064e159 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterDetailScreen.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterDetailScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* @@ -66,45 +67,52 @@ fun VeranstalterDetailScreen( ) } - val veranstaltungen = remember(veranstalterId) { - listOf( - VeranstaltungListUiModel( - id = 1L, - name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026", - datum = "25.-26. April 2026", - ort = "Reitanlage Stroblmair, Neumarkt/M., OO", - turnierAnzahl = 2, - nennungen = 87, - bewerbe = 26, - letzteAktivitaet = "22.03.2026 14:30", - status = VeranstaltungStatus.VORBEREITUNG, - ), - VeranstaltungListUiModel( - id = 2L, - name = "AWÖ-Cup Stadl-Paura 2025", - datum = "15.-17. Mai 2025", - ort = "Bundesgestüt Piber, Stadl-Paura", - turnierAnzahl = 2, - nennungen = 142, - bewerbe = 33, - letzteAktivitaet = "17.05.2025 18:45", - status = VeranstaltungStatus.ABGESCHLOSSEN, - ), - VeranstaltungListUiModel( - id = 3L, - name = "Linzer Pferdetage 2026", - datum = "12.-14. Juni 2026", - ort = "Reitsportzentrum Linz-Ebelsberg", - turnierAnzahl = 2, - nennungen = 23, - bewerbe = 30, - letzteAktivitaet = "20.03.2026 09:15", - status = VeranstaltungStatus.VORBEREITUNG, - ), - ) + // Liste aus dem Fake-Store (pro Veranstalter). Falls leer, einmalig seeden. + val storeList = FakeVeranstaltungStore.listFor(veranstalterId) + LaunchedEffect(veranstalterId) { + if (storeList.isEmpty()) { + FakeVeranstaltungStore.seedIfEmpty( + veranstalterId, + listOf( + VeranstaltungListUiModel( + id = 1L, + name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026", + datum = "25.-26. April 2026", + ort = "Reitanlage Stroblmair, Neumarkt/M., OO", + turnierAnzahl = 2, + nennungen = 87, + bewerbe = 26, + letzteAktivitaet = "22.03.2026 14:30", + status = VeranstaltungStatus.VORBEREITUNG, + ), + VeranstaltungListUiModel( + id = 2L, + name = "AWÖ-Cup Stadl-Paura 2025", + datum = "15.-17. Mai 2025", + ort = "Bundesgestüt Piber, Stadl-Paura", + turnierAnzahl = 2, + nennungen = 142, + bewerbe = 33, + letzteAktivitaet = "17.05.2025 18:45", + status = VeranstaltungStatus.ABGESCHLOSSEN, + ), + VeranstaltungListUiModel( + id = 3L, + name = "Linzer Pferdetage 2026", + datum = "12.-14. Juni 2026", + ort = "Reitsportzentrum Linz-Ebelsberg", + turnierAnzahl = 2, + nennungen = 23, + bewerbe = 30, + letzteAktivitaet = "20.03.2026 09:15", + status = VeranstaltungStatus.VORBEREITUNG, + ), + ) + ) + } } - val gefiltert = veranstaltungen.filter { v -> + val gefiltert = storeList.filter { v -> val matchesStatus = when (statusFilter) { VeranstaltungStatusFilter.ALLE -> true VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG @@ -251,6 +259,9 @@ fun VeranstalterDetailScreen( VeranstaltungListRow( veranstaltung = veranstaltung, onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) }, + onLoeschen = { + FakeVeranstaltungStore.remove(veranstalterId, veranstaltung.id) + } ) } } @@ -269,6 +280,7 @@ private fun KontaktSpalte(label: String, wert: String) { private fun VeranstaltungListRow( veranstaltung: VeranstaltungListUiModel, onOeffnen: () -> Unit, + onLoeschen: () -> Unit, ) { val statusColor = when (veranstaltung.status) { VeranstaltungStatus.VORBEREITUNG -> StatusVorbereitungColor @@ -276,8 +288,8 @@ private fun VeranstaltungListRow( VeranstaltungStatus.ABGESCHLOSSEN -> StatusAbgeschlossenColor } val statusText = when (veranstaltung.status) { - VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung" - VeranstaltungStatus.LIVE -> "Live" + VeranstaltungStatus.VORBEREITUNG -> "In Vorbereitung" + VeranstaltungStatus.LIVE -> "Aktiv" VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen" } @@ -329,14 +341,20 @@ private fun VeranstaltungListRow( StatSpalte("Letzte Aktivität", veranstaltung.letzteAktivitaet) } Spacer(Modifier.width(12.dp)) - // Bearbeiten-Icon - IconButton(onClick = onOeffnen) { - Icon( - Icons.Default.Edit, - contentDescription = "Öffnen", - tint = Color(0xFF9CA3AF), - modifier = Modifier.size(18.dp), - ) + // Aktionen: Zur Veranstaltung & Löschen + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onOeffnen, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) { + Text("Zur Veranstaltung") + } + // Roter Mülleimer + IconButton(onClick = onLoeschen) { + Icon( + Icons.Default.Delete, + contentDescription = "Löschen", + tint = Color(0xFFDC2626), + modifier = Modifier.size(18.dp), + ) + } } } } diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstaltungKonfigScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstaltungKonfigScreen.kt new file mode 100644 index 00000000..634630b8 --- /dev/null +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstaltungKonfigScreen.kt @@ -0,0 +1,109 @@ +package at.mocode.veranstalter.feature.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 androidx.compose.ui.unit.sp + +/** + * Formular zum Anlegen einer neuen Veranstaltung (Titel + Datumspfad). Pflichtfelder: Titel, Datum von/bis. + * Beim Speichern wird über Callback die Initialisierung ausgelöst (ID-Generierung erfolgt im Aufrufer). + */ +@Composable +fun VeranstaltungKonfigScreen( + veranstalterId: Long, + onAbbrechen: () -> Unit, + onSpeichern: (titel: String, datumVon: String, datumBis: String) -> Unit, +) { + var titel by remember { mutableStateOf("") } + var datumVon by remember { mutableStateOf("") } + var datumBis by remember { mutableStateOf("") } + + val datesPresent = datumVon.isNotBlank() && datumBis.isNotBlank() + // Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt, prüfen wir lexikografisch + val dateOrderOk = !datesPresent || datumBis >= datumVon + val valid = titel.isNotBlank() && datesPresent && dateOrderOk + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // Header + Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) { + Text( + text = "Neue Veranstaltung anlegen", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = "Erfassen Sie Titel und Zeitraum. Alle Turniere müssen innerhalb dieses Zeitraums stattfinden.", + fontSize = 13.sp, + color = Color(0xFF6B7280), + ) + } + + // Formular-Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + + OutlinedTextField( + value = titel, + onValueChange = { titel = it }, + label = { Text("Titel *") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = titel.isBlank(), + ) + + Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = datumVon, + onValueChange = { datumVon = it }, + label = { Text("von (YYYY-MM-DD) *") }, + singleLine = true, + modifier = Modifier.weight(1f), + isError = datumVon.isBlank(), + ) + OutlinedTextField( + value = datumBis, + onValueChange = { datumBis = it }, + label = { Text("bis (YYYY-MM-DD) *") }, + singleLine = true, + modifier = Modifier.weight(1f), + isError = datumBis.isBlank() || (datesPresent && !dateOrderOk), + ) + } + if (datesPresent && !dateOrderOk) { + Text("Das bis-Datum darf nicht vor dem von-Datum liegen.", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) + } + } + } + + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 40.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onAbbrechen) { Text("Abbrechen") } + Button(onClick = { onSpeichern(titel.trim(), datumVon.trim(), datumBis.trim()) }, enabled = valid) { + Text("Speichern") + } + } + } +} diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/AdminUebersichtScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/AdminUebersichtScreen.kt index 509b71b6..23260393 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/AdminUebersichtScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/AdminUebersichtScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,8 +40,24 @@ fun AdminUebersichtScreen( onVeranstaltungOeffnen: (Long) -> Unit, onPingService: () -> Unit = {}, ) { - // Placeholder-Daten für die UI-Struktur - val veranstaltungen = listOf() // leer bis Backend angebunden + // Placeholder-Daten für die UI-Struktur (sichtbar als Cards) + val sample = listOf( + VeranstaltungUiModel( + id = 1001, + name = "NEUMARKT/M., OÖ", + ort = "4221 NEUMARKT/M.", + datum = "12.–13.04.2026", + turnierAnzahl = 2, + nennungen = 0, + letzteAktivitaet = "vor 1 Min", + status = VeranstaltungStatus.VORBEREITUNG, + turniere = listOf( + TurnierUiModel(id = 26129, nummer = 26129, name = "CDN-C-NEU CDNP-C-NEU", bewerbAnzahl = 16), + TurnierUiModel(id = 26128, nummer = 26128, name = "CSN-C-NEU CSNP-C-NEU", bewerbAnzahl = 18), + ) + ) + ) + val veranstaltungen = remember { mutableStateListOf().also { it.addAll(sample) } } Column(modifier = Modifier.fillMaxSize()) { // KPI-Kacheln @@ -75,10 +93,6 @@ fun AdminUebersichtScreen( singleLine = true, ) - OutlinedButton(onClick = onPingService) { - Text("🔧 Ping") - } - // Status-Filter Chips StatusFilterChip("Alle", selected = true) StatusFilterChip("Vorbereitung", selected = false) @@ -123,11 +137,11 @@ fun AdminUebersichtScreen( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(vertical = 8.dp), ) { - items(veranstaltungen) { veranstaltung -> + items(items = veranstaltungen, key = { it.id }) { veranstaltung -> VeranstaltungCard( veranstaltung = veranstaltung, onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) }, - onLoeschen = { /* TODO */ }, + onLoeschen = { veranstaltungen.removeAll { it.id == veranstaltung.id } }, ) } } @@ -184,21 +198,21 @@ private fun KpiKachel( ) { Card( modifier = modifier, - border = BorderStroke(2.dp, akzentFarbe), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, akzentFarbe.copy(alpha = 0.4f)), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), ) { Column(modifier = Modifier.padding(12.dp)) { Text( text = label, - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.9f), fontWeight = FontWeight.Medium, ) Text( text = wert, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = akzentFarbe, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + color = akzentFarbe.copy(alpha = 0.9f), ) } } @@ -274,7 +288,7 @@ private fun VeranstaltungCard( Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp) } OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) { - Text("Öffnen", fontSize = 11.sp) + Text("Zum Turnier", fontSize = 11.sp) } } } @@ -297,7 +311,7 @@ private fun VeranstaltungCard( colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)), modifier = Modifier.height(32.dp), ) { - Text("Öffnen", fontSize = 12.sp) + Text("Zur Veranstaltung", fontSize = 12.sp) } IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt index 37036aa1..5771531e 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt @@ -36,10 +36,11 @@ fun DesktopApp() { val authState by authTokenManager.authState.collectAsState() - // Login-Gate: Nicht-authentifizierte Screens → Login - if (!authState.isAuthenticated && currentScreen !is AppScreen.Login) { + // Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt + if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding) { LaunchedEffect(Unit) { - nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen)) + // Wenn noch keine Authentifizierung vorhanden ist, zuerst Onboarding anzeigen + nav.navigateToScreen(AppScreen.Onboarding) } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt index ba74d68e..9b2a77ea 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt @@ -51,7 +51,7 @@ fun main() = application { Window( onCloseRequest = ::exitApplication, title = "Meldestelle", - state = WindowState(width = 1400.dp, height = 900.dp), + state = WindowState(width = 1600.dp, height = 900.dp), ) { DesktopApp() } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt index f68bec6a..89d68c3e 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow * Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet. */ class DesktopNavigationPort : NavigationPort { - private val _currentScreen = MutableStateFlow(AppScreen.Login()) + private val _currentScreen = MutableStateFlow(AppScreen.Onboarding) override val currentScreen: StateFlow = _currentScreen.asStateFlow() override fun navigateTo(route: String) { diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt index 558d273c..158384b5 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt @@ -3,6 +3,8 @@ package at.mocode.desktop.screens.layout import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Logout @@ -24,6 +26,8 @@ import at.mocode.turnier.feature.presentation.TurnierNeuScreen import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen +import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore +import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen @@ -264,6 +268,19 @@ private fun BreadcrumbSeparator() { ) } +@Composable +private fun InvalidContextNotice(message: String, onBack: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, color = Color(0xFFB91C1C), fontSize = 14.sp) + Spacer(Modifier.height(12.dp)) + Button(onClick = onBack) { Text("Zur Auswahl") } + } +} + /** * Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen. */ @@ -273,6 +290,18 @@ private fun DesktopContentArea( onNavigate: (AppScreen) -> Unit, ) { when (currentScreen) { + // Onboarding ohne Login + is AppScreen.Onboarding -> { + val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject() + at.mocode.desktop.screens.onboarding.OnboardingScreen( + onContinue = { _, _, _ -> + // Dummy-Token setzen, damit nach Onboarding der Admin-Flow sichtbar ist + authTokenManager.setToken("dummy.jwt.token") + onNavigate(AppScreen.Veranstaltungen) + } + ) + } + // Root-Screen: Admin-Übersicht is AppScreen.Veranstaltungen -> AdminUebersichtScreen( onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, @@ -291,26 +320,85 @@ private fun DesktopContentArea( onAbbrechen = { onNavigate(AppScreen.VeranstalterAuswahl) }, onSpeichern = { _, _, _ -> onNavigate(AppScreen.VeranstalterAuswahl) }, ) - is AppScreen.VeranstalterDetail -> VeranstalterDetailScreen( - veranstalterId = currentScreen.veranstalterId, - onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) }, - onVeranstaltungOeffnen = { vId -> - onNavigate(AppScreen.VeranstaltungUebersicht(currentScreen.veranstalterId, vId)) - }, - onVeranstaltungNeu = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) }, - ) - is AppScreen.VeranstaltungUebersicht -> VeranstaltungUebersichtScreen( - veranstalterId = currentScreen.veranstalterId, - veranstaltungId = currentScreen.veranstaltungId, - onZurueck = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) }, - onTurnierOeffnen = { tId -> - onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, tId)) - }, - onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.veranstaltungId)) }, - onZnsImport = { /* TODO: ZNS-Import Dialog für Turnier */ }, - onDbImport = { /* TODO: DB-Import Dialog */ }, - onDbExport = { /* TODO: DB-Export Dialog */ }, - ) + is AppScreen.VeranstalterDetail -> { + val vId = currentScreen.veranstalterId + if (!FakeVeranstalterStore.exists(vId)) { + InvalidContextNotice( + message = "Veranstalter (ID=$vId) nicht gefunden.", + onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } + ) + } else { + VeranstalterDetailScreen( + veranstalterId = vId, + onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) }, + onVeranstaltungOeffnen = { evtId -> + onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) + }, + onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) }, + ) + } + } + is AppScreen.VeranstaltungKonfig -> { + val vId = currentScreen.veranstalterId + if (!FakeVeranstalterStore.exists(vId)) { + InvalidContextNotice( + message = "Veranstalter (ID=$vId) nicht gefunden.", + onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } + ) + } else { + at.mocode.veranstalter.feature.presentation.VeranstaltungKonfigScreen( + veranstalterId = vId, + onAbbrechen = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, + onSpeichern = { titel, datumVon, datumBis -> + // ID generieren und in den Fake-Store einfügen + val id = System.currentTimeMillis() + at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore.addFirst( + vId, + at.mocode.veranstalter.feature.presentation.VeranstaltungListUiModel( + id = id, + name = titel, + datum = if (datumBis.isNotBlank()) "$datumVon – $datumBis" else datumVon, + ort = "", + turnierAnzahl = 1, + nennungen = 0, + bewerbe = 0, + letzteAktivitaet = "soeben", + status = at.mocode.frontend.core.designsystem.models.VeranstaltungStatus.VORBEREITUNG, + ) + ) + onNavigate(AppScreen.VeranstalterDetail(vId)) + } + ) + } + } + is AppScreen.VeranstaltungUebersicht -> { + val vId = currentScreen.veranstalterId + val evtId = currentScreen.veranstaltungId + if (!FakeVeranstalterStore.exists(vId)) { + InvalidContextNotice( + message = "Veranstalter (ID=$vId) nicht gefunden.", + onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } + ) + } else if (!FakeVeranstaltungStore.belongsTo(vId, evtId)) { + InvalidContextNotice( + message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.", + onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) } + ) + } else { + VeranstaltungUebersichtScreen( + veranstalterId = vId, + veranstaltungId = evtId, + onZurueck = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, + onTurnierOeffnen = { tId -> + onNavigate(AppScreen.TurnierDetail(evtId, tId)) + }, + onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(evtId)) }, + onZnsImport = { /* TODO */ }, + onDbImport = { /* TODO */ }, + onDbExport = { /* TODO */ }, + ) + } + } // Veranstaltungs-Screens is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen( @@ -325,16 +413,36 @@ private fun DesktopContentArea( ) // Turnier-Screens - is AppScreen.TurnierDetail -> TurnierDetailScreen( - veranstaltungId = currentScreen.veranstaltungId, - turnierId = currentScreen.turnierId, - onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) }, - ) - is AppScreen.TurnierNeu -> TurnierNeuScreen( - veranstaltungId = currentScreen.veranstaltungId, - onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) }, - onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) }, - ) + is AppScreen.TurnierDetail -> { + val evtId = currentScreen.veranstaltungId + if (!FakeVeranstaltungStore.exists(evtId)) { + InvalidContextNotice( + message = "Veranstaltung (ID=$evtId) nicht gefunden.", + onBack = { onNavigate(AppScreen.Veranstaltungen) } + ) + } else { + TurnierDetailScreen( + veranstaltungId = evtId, + turnierId = currentScreen.turnierId, + onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) }, + ) + } + } + is AppScreen.TurnierNeu -> { + val evtId = currentScreen.veranstaltungId + if (!FakeVeranstaltungStore.exists(evtId)) { + InvalidContextNotice( + message = "Veranstaltung (ID=$evtId) nicht gefunden.", + onBack = { onNavigate(AppScreen.Veranstaltungen) } + ) + } else { + TurnierNeuScreen( + veranstaltungId = evtId, + onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) }, + onSave = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) }, + ) + } + } // Ping-Screen is AppScreen.Ping -> { diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..f50ac58d --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingScreen.kt @@ -0,0 +1,92 @@ +package at.mocode.desktop.screens.onboarding + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +enum class ZnsStatus { NONE, LOCAL, SYNCED } + +@Composable +fun OnboardingScreen( + initialName: String = "", + initialKey: String = "", + initialZns: ZnsStatus = ZnsStatus.NONE, + onZnsSync: () -> Unit = {}, + onZnsUsb: () -> Unit = {}, + onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit, +) { + var geraetName by remember { mutableStateOf(initialName) } + var sharedKey by remember { mutableStateOf(initialKey) } + var znsStatus by remember { mutableStateOf(initialZns) } + var showPassword by remember { mutableStateOf(false) } + + val nameValid = geraetName.trim().length >= 3 + val keyValid = sharedKey.trim().length >= 8 + val canContinue = nameValid && keyValid + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Onboarding", style = MaterialTheme.typography.headlineSmall) + + Card { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = geraetName, + onValueChange = { geraetName = it }, + placeholder = { Text("z. B. Meldestelle") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = !nameValid && geraetName.isNotBlank() + ) + + Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = sharedKey, + onValueChange = { sharedKey = it }, + placeholder = { Text("z. B. Neumarkt2026") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = !keyValid && sharedKey.isNotBlank(), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val label = if (showPassword) "Verbergen" else "Anzeigen" + TextButton(onClick = { showPassword = !showPassword }) { + Text(label) + } + } + ) + + Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { + znsStatus = ZnsStatus.SYNCED + onZnsSync() + }, label = { Text("Aktualisieren") }) + AssistChip(onClick = { + znsStatus = ZnsStatus.LOCAL + onZnsUsb() + }, label = { Text("USB-Import") }) + Spacer(Modifier.width(8.dp)) + Text("Status: $znsStatus") + } + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) { + Text("Weiter zu den Veranstaltungen") + } + if (!canContinue) { + Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error) + } + } + } +}