Compare commits

...

5 Commits

Author SHA1 Message Date
edfe05cbe3 chore: entferne ungenutzte Importe, reduziere Redundanz in DesktopMainLayout und verlagere Komponenten in Module
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-20 15:59:36 +02:00
6feb139a46 chore: füge SyncManager und Peer-Zähler hinzu, verbessere Navigation-Breadcrumbs und passe MD3-Stil an
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 15:09:25 +02:00
b94e0f2d9d chore: implementiere Zustandsprüfung für DiscoveryService und ConnectivityTracker, verbessere Plug-and-Play-Kompatibilität und optimiere LAN-Discovery
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 14:39:48 +02:00
8806d11e3c chore: implementiere Auth-Status-abhängige Navigation und Icons, deaktiviere Module ohne Initialisierung und passe NavRail sowie Header für besseren UX an
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-20 14:23:47 +02:00
a1bf93342e chore: fix 500 errors in ping-service, improve security annotations, update parameter mapping, integrate Resilience4j with Kotlin, and refine test suite 2026-04-20 14:21:53 +02:00
10 changed files with 1229 additions and 1002 deletions

View File

@ -0,0 +1,41 @@
---
type: Journal
status: FINAL
owner: Curator
date: 2026-04-20
---
# Session Log Finalisierung Start-Sequenz & Layout (Phase 13)
## 🏗️ Status-Update
Die Nachmittags-Session am 20. April 2026 wurde erfolgreich abgeschlossen. Die gesamte Start-Sequenz, die Infrastruktur-Integration und das globale Layout wurden nach dem **ADR-0024 Plug-and-Play Pattern** finalisiert.
## 🛠️ Umfang & Änderungen
### 1. Onboarding & Geräte-Initialisierung
- **Sidebar-Blocking:** Fachliche Module sind deaktiviert, bis das Gerät initialisiert ist.
- **Client-Datensicherheit:** Der `backupPath` ist für alle Rollen verpflichtend (Lokale Datensouveränität).
- **Navigations-Fix:** Login-Sackgasse behoben (`navigateBack` implementiert).
- **Setup-Workflow:** Nahtloser Übergang zur Veranstaltungs-Verwaltung nach Abschluss ohne Neustart.
### 2. Infrastruktur & Sicherheit
- **Lifecycle-Awareness:** `ConnectivityTracker` und `DiscoveryService` starten reaktiv erst nach erfolgreicher Initialisierung.
- **Plug-and-Play:** Dienste bleiben inaktiv, solange kein Gerätename/Key vorliegt (Ressourcenschonung).
### 3. Globale Navigation & Layout (Vision_03)
- **Top-Bar:** Integration eines pulsierenden WebSocket-Sync-Indikators (Echtzeit-Peer-Count).
- **Breadcrumbs:** Konsistentes MD3-Styling mit Home-Anker und navigierbaren Pfaden für alle Bounded Contexts.
- **Navigation-Rail:** Optimierung auf MD3-Standards (Ripple-Effekt, Surface-Indikatoren, Tooltips).
- **Footer:** Umstellung auf High-Density Layout (28dp Höhe, optimierte Schriftgrößen) für maximale Informationsdichte.
- **Refactoring:** `DesktopMainLayout.kt` von 1274 Zeilen auf ca. 95 Zeilen reduziert.
- **Modul-Aufsplittung:** Extraktion der Sub-Komponenten in `NavRail.kt`, `TopHeader.kt`, `ContentArea.kt` und `FooterBar.kt` im Paket `at.mocode.frontend.shell.desktop.screens.layout.components`.
- **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`.
## 📐 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`).
- **Offline-First:** Visuelles Feedback über lokale DB, LAN-Peers und Cloud-Sync ist jederzeit präsent.
---
*Dokumentation erstellt durch den Curator im Rahmen des "Meldestelle"-Protokolls.*

View File

@ -0,0 +1,44 @@
# Journal: Behebung von 500er Fehlern im Ping-Service & Security-Fixes
**Datum:** 19. April 2026
**Status:** Abgeschlossen
**Agent:** 🏗️ [Lead Architect] | 🧐 [QA Specialist] | 🧹 [Curator]
## 🎯 Zielsetzung
Nach der gestrigen Umstrukturierung traten beim `ping-service` HTTP 500 Fehler bei autorisierten API-Aufrufen auf. Ziel war die Identifikation der Ursachen (Security, Parameter-Mapping, Resilience) und deren Behebung sowie die Aktualisierung der Testsuite.
## 🛠️ Durchgeführte Änderungen
### 1. Security-Mapping & Rollen-Korrektur
* Im `PingController` wurden die `@PreAuthorize`-Annotationen korrigiert. Da der `KeycloakRoleConverter` das Präfix `ROLE_` hartkodiert hinzufügt und die Rollen in Großbuchstaben umwandelt, wurden die Prüfungen von `MELD_USER` auf `ROLE_MELD_USER` (bzw. `ROLE_MELD_ADMIN`) angepasst.
* Der Import der `AccessDeniedException` im `PingExceptionHandler` wurde auf die korrekte Spring Security Klasse fixiert, um 403-Fehler sauber zu fangen und nicht in 500er zu verwandeln.
### 2. API-Konsistenz & Parameter-Mapping
* Der Query-Parameter für `/api/ping/sync` wurde explizit auf `lastSyncTimestamp` gemappt, um mit dem Postman-Aufruf und den Anforderungen des Frontends konsistent zu sein.
### 3. Resilience4j & Coroutines
* Die Bibliothek `resilience4j-kotlin` wurde in die `libs.versions.toml` aufgenommen und im `ping-service` eingebunden. Dies stellt sicher, dass der `@CircuitBreaker` korrekt mit Kotlin `suspend` Funktionen zusammenarbeitet und Exceptions nicht unkontrolliert durchschlagen.
### 4. Test-Aktualisierung
* `PingControllerTest.kt` wurde angepasst, um den neuen Parameter-Namen `lastSyncTimestamp` zu verwenden.
* Alle 5 Tests im `PingControllerTest` verlaufen nun erfolgreich.
## ✅ Verifizierung
* `./gradlew :backend:services:ping:ping-service:test`: **ERFOLGREICH** (5/5 Tests passed)
* Manueller Check der Parameter-Namen gegen Postman-Anforderungen: **ERFOLGREICH**
* Verifizierung des Rollen-Mappings im `KeycloakRoleConverter` gegen Controller-Annotationen: **KONSISTENT**
## 🧹 Fazit
Die "letzte Meile" der Service-Kommunikation ist nun stabil. Durch das verbesserte Exception-Handling und die korrekte Resilience-Integration liefert der Service nun aussagekräftige HTTP-Statuscodes statt generischer 500er Fehler.
### Nachtrag 20:30
* **Networking-Fix:** `GlobalSecurityConfig` angepasst, um `jwk-set-uri` primär aus Spring-Properties oder Environment-Variables zu lesen. Default auf `localhost:8180` für IDE-Betrieb korrigiert, um `UnknownHostException: keycloak` zu vermeiden.
* **Exception-Handling:** `PingExceptionHandler` um generischen `Exception`-Handler erweitert, um auch Security-Initialisierungsfehler (wie JWT-Decoder-Probleme) sauber abzufangen und zu loggen.
### Nachtrag 21:25
* **Re-Fix Circuit Breaker Fallback:** Nachdem ein fehlerhafter Zwischenversuch (möglicherweise durch einen anderen Agenten) die `suspend`-Markierung wieder eingeführt hatte, wurde diese nun final entfernt. Die Signatur `fallbackPing(simulate: Boolean, ex: Throwable)` ohne `suspend` ist die einzig stabile Variante für Resilience4j in Kombination mit Spring Boot 3 AOP-Proxies und Kotlin Coroutines. Tests bestätigen die strukturelle Korrektheit.
### Nachtrag 21:45
* **Stochastic Simulation:** Die Zufallskomponente (`Random.nextDouble() < 0.6`) wurde in der `enhancedPing`-Methode wieder eingeführt.
* **Logik:** Wenn `simulate=true` übergeben wird, tritt der Fehler nun nur noch in ca. 60% der Fälle auf, was ein realistisches "intermittierendes" Fehlerszenario für den Circuit Breaker Test darstellt. In den restlichen 40% wird die Anfrage trotz Simulationsmodus erfolgreich verarbeitet.
* **Logging:** Zusätzliches Log-Statement für den "Lucky Pass" Fall hinzugefügt, um die Simulationstransparenz in der Konsole zu wahren.

View File

@ -21,11 +21,8 @@ class ConnectivityTracker : KoinComponent {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
init { fun startTracking() {
startTracking() if (scope.isActive && _isOnline.value) return // Bereits aktiv (Dummy-Check)
}
private fun startTracking() {
scope.launch { scope.launch {
while (isActive) { while (isActive) {
_isOnline.value = checkConnection() _isOnline.value = checkConnection()

View File

@ -72,33 +72,26 @@ actual fun DeviceInitializationConfig(
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) VisualTransformation.None else PasswordVisualTransformation(),
imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done, imeAction = ImeAction.Next,
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }, onNext = { focusManager.moveFocus(FocusDirection.Next) }
onDone = {
if (DeviceInitializationValidator.canContinue(settings)) {
viewModel.completeInitialization()
} else {
focusManager.clearFocus()
}
}
), ),
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 }
) )
if (settings.networkRole == NetworkRole.MASTER) { MsFilePicker(
MsFilePicker( label = "Backup-Verzeichnis (Pfad)",
label = "Backup-Verzeichnis (Pfad)", selectedPath = settings.backupPath,
selectedPath = settings.backupPath, onFileSelected = { selectedPath ->
onFileSelected = { selectedPath -> 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) )
)
if (settings.networkRole == NetworkRole.MASTER) {
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium) Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium)
Slider( Slider(
value = settings.syncInterval.toFloat(), value = settings.syncInterval.toFloat(),
@ -106,7 +99,19 @@ actual fun DeviceInitializationConfig(
valueRange = 1f..60f, valueRange = 1f..60f,
steps = 59 steps = 59
) )
} else {
// Button zum Abschließen für Clients, da diese keinen Slider/Clients haben
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.completeInitialization() },
modifier = Modifier.fillMaxWidth(),
enabled = DeviceInitializationValidator.canContinue(settings)
) {
Text("Konfiguration abschließen")
}
}
if (settings.networkRole == NetworkRole.MASTER) {
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)

View File

@ -8,14 +8,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.shell.desktop.screens.layout.DesktopMainLayout
import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.theme.AppTheme import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.shell.desktop.screens.layout.DesktopMainLayout
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -72,7 +72,7 @@ fun DesktopApp() {
val returnTo = screen.returnTo ?: AppScreen.VeranstaltungVerwaltung val returnTo = screen.returnTo ?: AppScreen.VeranstaltungVerwaltung
nav.navigateToScreen(returnTo) nav.navigateToScreen(returnTo)
}, },
onBack = { /* Desktop hat keine PortalDashboard-Page */ }, onBack = { nav.navigateBack() },
) )
else -> { else -> {
@ -85,6 +85,7 @@ fun DesktopApp() {
authTokenManager.clearToken() authTokenManager.clearToken()
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.VeranstaltungVerwaltung)) nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.VeranstaltungVerwaltung))
}, },
isAuthenticated = authState.isAuthenticated
) )
} }
} }

View File

@ -0,0 +1,363 @@
package at.mocode.frontend.shell.desktop.screens.layout.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.features.billing.presentation.BillingScreen
import at.mocode.frontend.features.billing.presentation.BillingViewModel
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationScreen
import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerIntent
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerScreen
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerViewModel
import at.mocode.frontend.features.nennung.presentation.NennungManagementScreen
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import at.mocode.frontend.features.ping.presentation.PingScreen
import at.mocode.frontend.features.ping.presentation.PingViewModel
import at.mocode.frontend.features.profile.presentation.ProfileScreen
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
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 org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
onSettingsChange: (DeviceInitializationSettings) -> Unit,
) {
when (currentScreen) {
// DeviceInitialization (Geräte-Setup)
is AppScreen.DeviceInitialization -> {
println("[Screen] Rendering DeviceInitialization")
val viewModel = koinViewModel<DeviceInitializationViewModel> {
parametersOf({ finalSettings: DeviceInitializationSettings ->
DeviceInitializationSettingsManager.saveSettings(finalSettings)
// Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert
val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>()
authTokenManager.setToken(finalSettings.sharedKey)
onSettingsChange(finalSettings)
onNavigate(AppScreen.VeranstaltungVerwaltung)
})
}
DeviceInitializationScreen(viewModel = viewModel)
}
// 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) }
)
}
// --- ZNS Importer ---
is AppScreen.StammdatenImport -> {
StammdatenImportScreen(
onBack = onBack
)
}
// --- Pferde-Verwaltung & Profil ---
is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> {
val viewModel = koinViewModel<PferdeViewModel>()
PferdeScreen(viewModel = viewModel)
}
is AppScreen.PferdProfil -> {
val viewModel = koinViewModel<PferdeViewModel>()
// In der aktuellen Ausbaustufe wählen wir das Pferd im ViewModel aus
LaunchedEffect(currentScreen.id) {
// Mock: Wir suchen das Pferd in den Suchergebnissen
viewModel.uiState.searchResults.find { it.id == currentScreen.id.toString() }?.let {
viewModel.selectPferd(it)
}
}
PferdeScreen(viewModel = viewModel)
}
// --- Reiter-Verwaltung & Profil ---
is AppScreen.Reiter, is AppScreen.ReiterVerwaltung -> {
val viewModel = koinViewModel<ReiterViewModel>()
ReiterScreen(viewModel = viewModel)
}
is AppScreen.ReiterProfil -> {
val viewModel = koinViewModel<ReiterViewModel>()
LaunchedEffect(currentScreen.id) {
viewModel.uiState.searchResults.find { it.id == currentScreen.id.toString() }?.let {
viewModel.selectReiter(it)
}
}
ReiterScreen(viewModel = viewModel)
}
// --- Verein-Verwaltung & Profil ---
is AppScreen.Vereine, is AppScreen.VereinVerwaltung -> {
println("[Screen] Rendering VereinVerwaltung (VereinScreen)")
val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(viewModel = vereinViewModel)
}
is AppScreen.VereinProfil -> {
println("[Screen] Rendering VereinProfil #${currentScreen.id}")
val vereinViewModel: VereinViewModel = koinViewModel()
// Mock: Selektion im ViewModel (falls unterstützt)
VereinScreen(viewModel = vereinViewModel)
}
// --- Funktionaer-Verwaltung & Profil ---
is AppScreen.FunktionaerVerwaltung -> {
val viewModel = koinViewModel<FunktionaerViewModel>()
FunktionaerScreen(viewModel = viewModel)
}
is AppScreen.FunktionaerProfil -> {
val viewModel = koinViewModel<FunktionaerViewModel>()
LaunchedEffect(currentScreen.id) {
viewModel.state.value.list.find { it.id == currentScreen.id }?.let {
viewModel.send(FunktionaerIntent.Select(it))
}
}
FunktionaerScreen(viewModel = viewModel)
}
// --- Veranstalter-Verwaltung & Profil ---
is AppScreen.VeranstalterVerwaltung -> VeranstalterVerwaltungScreen(
onBack = onBack,
onNew = { onNavigate(AppScreen.VeranstalterNeu) },
onEdit = { onNavigate(AppScreen.VeranstalterProfil(it)) }
)
is AppScreen.VeranstalterProfil -> VeranstalterDetail(
veranstalterId = currentScreen.id,
onBack = onBack,
onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.VeranstaltungProfil(currentScreen.id, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(currentScreen.id)) },
)
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl(
onBack = onBack,
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
)
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
onCancel = onBack,
onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
)
is AppScreen.VeranstalterDetail -> {
val vId = currentScreen.veranstalterId
VeranstalterDetail(
veranstalterId = vId,
onBack = onBack,
onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungProfil(vId, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) },
)
}
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
VeranstaltungKonfigScreen(
veranstalterId = vId,
onAbbrechen = onBack,
onSpeichern = { _, _, _ ->
// In-Memory Store Simulation
// val allEvents = Store.allEvents()
// val newId = (allEvents.maxOfOrNull { it.id } ?: 0L) + 1L
// ...
onNavigate(AppScreen.VeranstaltungProfil(vId, 0L)) // Mock
}
)
}
is AppScreen.VeranstaltungProfil -> {
VeranstaltungProfilScreen(
veranstalterId = currentScreen.veranstalterId,
veranstaltungId = currentScreen.veranstaltungId,
onBack = onBack,
onTurnierOpen = { tId ->
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, tId))
},
onTurnierNeu = {
onNavigate(AppScreen.TurnierNeu(currentScreen.veranstaltungId))
},
onNavigateToVeranstalterProfil = { verId ->
onNavigate(AppScreen.VeranstalterProfil(verId))
}
)
}
is AppScreen.VeranstaltungDetail -> {
VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
onBack = onBack,
onTurnierOeffnen = { tId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tId)) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) }
)
}
is AppScreen.VeranstaltungNeu -> {
VeranstaltungNeuScreen(
onBack = onBack,
onSave = { onBack() }
)
}
is AppScreen.TurnierDetail -> {
val evtId = currentScreen.veranstaltungId
val parent = at.mocode.frontend.shell.desktop.data.Store.vereine.firstOrNull { v ->
at.mocode.frontend.shell.desktop.data.Store.eventsFor(v.id).any { it.id == evtId }
}
if (parent == null) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = onBack
)
} else {
val veranstaltung =
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) }
val nennungViewModel: at.mocode.frontend.features.turnier.presentation.TurnierNennungViewModel =
org.koin.compose.koinInject { parametersOf(currentScreen.turnierId) }
val stammdatenViewModel: at.mocode.frontend.features.turnier.presentation.TurnierStammdatenViewModel =
org.koin.compose.koinInject()
TurnierDetailScreen(
veranstaltungId = evtId,
turnierId = currentScreen.turnierId,
bewerbViewModel = bewerbViewModel,
nennungViewModel = nennungViewModel,
stammdatenViewModel = stammdatenViewModel,
eventVon = veranstaltung?.datumVon,
eventBis = veranstaltung?.datumBis,
eventOrt = veranstaltung?.ort,
veranstalterName = parent.name,
veranstalterOrt = parent.ort,
veranstalterLogoUrl = veranstaltung?.logoUrl,
)
}
}
is AppScreen.TurnierNeu -> {
val evtId = currentScreen.veranstaltungId
val parent = at.mocode.frontend.shell.desktop.data.Store.vereine.firstOrNull { v ->
at.mocode.frontend.shell.desktop.data.Store.eventsFor(v.id).any { it.id == evtId }
}
if (parent == null) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = onBack
)
} else {
TurnierWizard(
veranstalterId = parent.id,
veranstaltungId = evtId,
onBack = onBack,
onSaved = { _ -> onBack() }
)
}
}
is AppScreen.Billing -> {
val billingViewModel = koinViewModel<BillingViewModel>()
BillingScreen(
viewModel = billingViewModel,
veranstaltungId = currentScreen.veranstaltungId,
onBack = onBack
)
}
is AppScreen.NennungsEingang -> {
NennungsEingangScreen(
onBack = onBack
)
}
is AppScreen.EntryManagement -> {
val viewModel = koinViewModel<NennungViewModel>()
NennungManagementScreen(viewModel = viewModel)
}
is AppScreen.ConnectivityCheck -> {
val viewModel = koinViewModel<PingViewModel>()
PingScreen(viewModel = viewModel)
}
is AppScreen.Profile -> {
val viewModel = koinViewModel<ProfileViewModel>()
ProfileScreen(viewModel = viewModel)
}
is AppScreen.Home, is AppScreen.Dashboard -> {
AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) }
)
}
else -> {
InvalidContextNotice(
message = "Dieser Screen (${currentScreen::class.simpleName}) ist noch nicht für Desktop optimiert.",
onBack = onBack
)
}
}
}
@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") }
}
}

View File

@ -0,0 +1,138 @@
package at.mocode.frontend.shell.desktop.screens.layout.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Dataset
import androidx.compose.material.icons.filled.Lan
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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.network.ConnectivityTracker
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun DesktopFooterBar(
settings: DeviceInitializationSettings,
onSetupClick: () -> Unit = {}
) {
val connectivityTracker = koinInject<ConnectivityTracker>()
val discoveryService = koinInject<NetworkDiscoveryService>()
val znsImporter = koinInject<ZnsImportProvider>()
val online by connectivityTracker.isOnline.collectAsState()
val znsState = znsImporter.state
val discoveredServices = remember { mutableStateOf(discoveryService.getDiscoveredServices()) }
val deviceName = settings.deviceName.ifBlank { "Unbekannt" }
// Periodisches Update der LAN-Geräte (mDNS)
LaunchedEffect(settings.isConfigured, settings.deviceName) {
if (settings.isConfigured && settings.deviceName.isNotBlank()) {
discoveryService.startDiscovery()
connectivityTracker.startTracking()
while (true) {
discoveredServices.value = discoveryService.getDiscoveredServices()
delay(5000.milliseconds)
}
} else {
discoveryService.stopDiscovery()
connectivityTracker.stopTracking()
}
}
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 1.dp,
modifier = Modifier.clickable(onClick = onSetupClick)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(28.dp)
.padding(horizontal = Dimens.SpacingS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
// Status: Cloud Sync
StatusIndicator(
icon = if (online) Icons.Filled.CloudDone else Icons.Filled.CloudOff,
label = if (online) "Cloud OK" else "Lokal",
color = if (online) AppColors.Success else MaterialTheme.colorScheme.error
)
VerticalDivider(modifier = Modifier.height(12.dp), color = MaterialTheme.colorScheme.outlineVariant)
// Status: LAN Devices (mDNS)
val deviceCount = discoveredServices.value.size
StatusIndicator(
icon = Icons.Filled.Lan,
label = if (deviceCount > 0) "$deviceName ($deviceCount Peers)" else "$deviceName (Solitär)",
color = if (deviceCount > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
VerticalDivider(modifier = Modifier.height(12.dp), color = MaterialTheme.colorScheme.outlineVariant)
// Status: ZNS Stammdaten
val lastSync = znsState.lastSyncVersion
val znsLabel = if (lastSync != null) "ZNS: $lastSync" else "ZNS: Kein Sync"
StatusIndicator(
icon = Icons.Default.Dataset,
label = znsLabel,
color = if (lastSync != null) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "v2.4.0-rc1 | Desktop-Alpha",
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
private fun StatusIndicator(
icon: ImageVector,
label: String,
color: Color
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color.copy(alpha = 0.8f),
modifier = Modifier.size(14.dp)
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@ -0,0 +1,195 @@
package at.mocode.frontend.shell.desktop.screens.layout.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
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.vector.ImageVector
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen
@Composable
fun DesktopNavRail(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
isConfigured: Boolean
) {
Surface(
modifier = Modifier.fillMaxHeight().width(Dimens.NavRailWidth),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
tonalElevation = 2.dp
) {
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = Dimens.SpacingM),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
NavRailItem(
icon = Icons.Default.Adjust,
label = "Logo",
selected = false,
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
enabled = true
)
Spacer(Modifier.height(Dimens.SpacingL))
// Navigations-Items
NavRailItem(
icon = Icons.Default.Dashboard,
label = "Admin",
selected = currentScreen is AppScreen.VeranstaltungVerwaltung || currentScreen is AppScreen.VeranstaltungDetail,
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
enabled = isConfigured
)
NavRailItem(
icon = Icons.Default.CloudDownload,
label = "ZNS-Import",
selected = currentScreen is AppScreen.StammdatenImport,
onClick = { onNavigate(AppScreen.StammdatenImport) },
enabled = isConfigured
)
var showStammdatenMenu by remember { mutableStateOf(false) }
Box {
NavRailItem(
icon = Icons.Default.Storage,
label = "Stammdaten",
selected = currentScreen is AppScreen.Vereine || currentScreen is AppScreen.VereinVerwaltung ||
currentScreen is AppScreen.Reiter || currentScreen is AppScreen.ReiterVerwaltung ||
currentScreen is AppScreen.Pferde || currentScreen is AppScreen.PferdVerwaltung ||
currentScreen is AppScreen.FunktionaerVerwaltung,
onClick = { showStammdatenMenu = true },
enabled = isConfigured
)
DropdownMenu(
expanded = showStammdatenMenu && isConfigured,
onDismissRequest = { showStammdatenMenu = false },
offset = DpOffset(Dimens.NavRailWidth, 0.dp)
) {
DropdownMenuItem(
text = { Text("Vereine") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.Vereine)
},
leadingIcon = { Icon(Icons.Default.People, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Reiter") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.Reiter)
},
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Pferde") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.Pferde)
},
leadingIcon = { Icon(Icons.Default.Pets, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Richter") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.FunktionaerVerwaltung)
},
leadingIcon = { Icon(Icons.Default.Gavel, contentDescription = null) }
)
}
}
NavRailItem(
icon = Icons.Default.Email,
label = "Mails",
selected = currentScreen is AppScreen.NennungsEingang,
onClick = { onNavigate(AppScreen.NennungsEingang) },
enabled = isConfigured
)
Spacer(Modifier.weight(1f))
NavRailItem(
icon = Icons.Default.AppRegistration,
label = "Setup",
selected = currentScreen is AppScreen.DeviceInitialization,
onClick = { onNavigate(AppScreen.DeviceInitialization) },
enabled = true
)
NavRailItem(
icon = Icons.Default.WifiTethering,
label = "Connectivity",
selected = currentScreen is AppScreen.ConnectivityCheck,
onClick = { onNavigate(AppScreen.ConnectivityCheck) },
enabled = true
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NavRailItem(
icon: ImageVector,
label: String,
selected: Boolean,
onClick: () -> Unit,
enabled: Boolean = true
) {
val interactionSource = remember { MutableInteractionSource() }
val contentColor = if (!enabled) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
} else if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
positioning = TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 4.dp
),
tooltip = {
PlainTooltip(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Text(label)
}
},
state = rememberTooltipState()
) {
Box(
modifier = Modifier
.size(Dimens.NavRailWidth)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = ripple(bounded = false, radius = 24.dp),
onClick = onClick
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = contentColor,
modifier = Modifier.size(24.dp)
)
}
}
}

View File

@ -0,0 +1,400 @@
package at.mocode.frontend.shell.desktop.screens.layout.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen
@Composable
fun DesktopTopHeader(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
isAuthenticated: Boolean,
connectedPeersCount: Int = 0
) {
Surface(
modifier = Modifier.fillMaxWidth().height(Dimens.TopBarHeight),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = Dimens.SpacingL),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (currentScreen !is AppScreen.DeviceInitialization) {
IconButton(
onClick = onBack,
modifier = Modifier.size(Dimens.IconSizeM)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.width(Dimens.SpacingS))
}
// Home Icon als Anker
IconButton(
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
modifier = Modifier.size(Dimens.IconSizeM)
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = "Home",
modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.primary
)
}
// Breadcrumb-Segmente
BreadcrumbContent(currentScreen, onNavigate)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
// Sync-Status Indikator
val syncColor = if (connectedPeersCount > 0) AppColors.Success else MaterialTheme.colorScheme.outline
val syncText = if (connectedPeersCount > 0) "$connectedPeersCount Peer(s)" else "Offline"
val infiniteTransition = rememberInfiniteTransition()
val pulseAlpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS),
modifier = Modifier
.background(syncColor.copy(alpha = 0.1f), MaterialTheme.shapes.small)
.padding(horizontal = Dimens.SpacingS, vertical = 4.dp)
) {
Surface(
modifier = Modifier.size(8.dp),
shape = MaterialTheme.shapes.extraSmall,
color = if (connectedPeersCount > 0) syncColor.copy(alpha = pulseAlpha) else syncColor
) {}
Text(
text = syncText,
style = MaterialTheme.typography.labelSmall,
color = syncColor
)
}
VerticalDivider(
modifier = Modifier.height(16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
// Profil / Logout Bereich
if (isAuthenticated) {
Text(
text = "Administrator",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(onClick = onLogout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Abmelden",
modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.error
)
}
} else {
Text(
text = "Gast",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(onClick = { onNavigate(AppScreen.Login()) }) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "Anmelden",
modifier = Modifier.size(Dimens.IconSizeM),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
@Composable
private fun BreadcrumbContent(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit
) {
val textStyle = MaterialTheme.typography.bodyMedium
val clickableColor = MaterialTheme.colorScheme.primary
val activeColor = MaterialTheme.colorScheme.onSurface
when (currentScreen) {
is AppScreen.VeranstalterAuswahl -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.VeranstalterNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Neuer Veranstalter",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter-Verwaltung",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId))
},
)
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.VeranstaltungVerwaltung -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltungs-Verwaltung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.VeranstaltungDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltungs-Verwaltung",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.id}",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.VeranstaltungNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltungs-Verwaltung",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) },
)
BreadcrumbSeparator()
Text(
text = "Neue Veranstaltung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.TurnierDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier #${currentScreen.turnierId}",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.TurnierNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Neues Turnier",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.Billing -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier #${currentScreen.turnierId}",
style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable {
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, currentScreen.turnierId))
},
)
BreadcrumbSeparator()
Text(
text = "Abrechnung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.StammdatenImport -> {
BreadcrumbSeparator()
Text(
text = "ZNS Stammdaten Import",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.Vereine -> {
BreadcrumbSeparator()
Text(
text = "Vereine",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.Reiter -> {
BreadcrumbSeparator()
Text(
text = "Reiter",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.Pferde -> {
BreadcrumbSeparator()
Text(
text = "Pferde",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.FunktionaerVerwaltung -> {
BreadcrumbSeparator()
Text(
text = "Richter-Verwaltung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.NennungsEingang -> {
BreadcrumbSeparator()
Text(
text = "Nennungs-Eingang",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.ConnectivityCheck -> {
BreadcrumbSeparator()
Text(
text = "Connectivity Check",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.DeviceInitialization -> {
BreadcrumbSeparator()
Text(
text = "Geräte-Initialisierung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
is AppScreen.Login -> {
BreadcrumbSeparator()
Text(
text = "Administrator-Login",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
)
}
else -> {
// Generischer Fall für ungelistete Screens
}
}
}
@Composable
private fun BreadcrumbSeparator() {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(16.dp).padding(horizontal = 4.dp),
tint = MaterialTheme.colorScheme.outline
)
}