From 7581f15dfbf576be5252159d8722fd49a42ce00c Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Thu, 16 Apr 2026 00:00:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20f=C3=BCge=20ConnectivityTracker=20hinzu?= =?UTF-8?q?,=20erweitere=20networkModule,=20aktualisiere=20DesktopFooterBa?= =?UTF-8?q?r=20mit=20Ger=C3=A4testatus=20und=20mDNS-Discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: StefanMoCoAt --- .../core/network/ConnectivityTracker.kt | 48 ++++++++++++ .../frontend/core/network/NetworkModule.kt | 10 ++- .../screens/layout/DesktopMainLayout.kt | 78 +++++++++++++------ 3 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt new file mode 100644 index 00000000..c4870400 --- /dev/null +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ConnectivityTracker.kt @@ -0,0 +1,48 @@ +package at.mocode.frontend.core.network + +import io.ktor.client.* +import io.ktor.client.request.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.qualifier.named + +/** + * Überwacht die Konnektivität zum API-Gateway. + */ +class ConnectivityTracker : KoinComponent { + private val client: HttpClient by inject(named("baseHttpClient")) + private val _isOnline = MutableStateFlow(true) + val isOnline: StateFlow = _isOnline.asStateFlow() + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + init { + startTracking() + } + + private fun startTracking() { + scope.launch { + while (isActive) { + _isOnline.value = checkConnection() + delay(10_000) // Alle 10 Sekunden prüfen + } + } + } + + private suspend fun checkConnection(): Boolean { + return try { + val response = client.get(NetworkConfig.baseUrl.trimEnd('/') + "/ping") + response.status.value in 200..299 + } catch (e: Exception) { + false + } + } + + fun stopTracking() { + scope.cancel() + } +} diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt index 394f573e..5028cb17 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt @@ -1,5 +1,7 @@ package at.mocode.frontend.core.network +import at.mocode.frontend.core.network.discovery.discoveryModule +import at.mocode.frontend.core.network.sync.syncModule import io.ktor.client.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* @@ -10,13 +12,13 @@ import kotlinx.serialization.json.Json import org.koin.core.module.Module import org.koin.core.qualifier.named import org.koin.dsl.module -import at.mocode.frontend.core.network.discovery.discoveryModule -import at.mocode.frontend.core.network.sync.syncModule /** * Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth. */ -interface TokenProvider { fun getAccessToken(): String? } +interface TokenProvider { + fun getAccessToken(): String? +} /** * Koin-Modul mit zwei HttpClient-Instanzen: @@ -26,6 +28,8 @@ interface TokenProvider { fun getAccessToken(): String? } val networkModule: Module = module { includes(discoveryModule, syncModule) + single { ConnectivityTracker() } + // 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token) single(named("baseHttpClient")) { HttpClient { 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 30ca165d..4a45b5de 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 @@ -21,6 +21,8 @@ import at.mocode.desktop.screens.onboarding.SettingsManager import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.navigation.AppScreen +import at.mocode.frontend.core.network.ConnectivityTracker +import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService import at.mocode.frontend.features.billing.presentation.BillingScreen import at.mocode.frontend.features.billing.presentation.BillingViewModel import at.mocode.frontend.features.nennung.presentation.NennungViewModel @@ -40,8 +42,10 @@ import at.mocode.turnier.feature.presentation.TurnierDetailScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen +import kotlinx.coroutines.delay import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration.Companion.milliseconds // Primärfarbe der TopBar (kann später ins Theme ausgelagert werden) private val TopBarColor = Color(0xFF1E3A8A) @@ -91,7 +95,7 @@ fun DesktopMainLayout( } HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant) - DesktopFooterBar() + DesktopFooterBar(settings = onboardingSettings) } } } @@ -249,7 +253,10 @@ private fun DesktopTopHeader( BreadcrumbContent(currentScreen, onNavigate) } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) + ) { // Profil / Logout Bereich Text( text = "Administrator", @@ -296,6 +303,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.VeranstalterDetail -> { BreadcrumbSeparator() Text( @@ -309,6 +317,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.VeranstaltungProfil -> { BreadcrumbSeparator() Text( @@ -330,6 +339,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.VeranstaltungDetail -> { BreadcrumbSeparator() Text( @@ -337,6 +347,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium, ) } + is AppScreen.VeranstaltungNeu -> { BreadcrumbSeparator() Text( @@ -344,6 +355,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium, ) } + is AppScreen.TurnierDetail -> { BreadcrumbSeparator() Text( @@ -359,6 +371,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.Billing -> { BreadcrumbSeparator() Text( @@ -382,6 +395,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.TurnierNeu -> { BreadcrumbSeparator() Text( @@ -413,6 +427,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.Meisterschaften -> { BreadcrumbSeparator() Text( @@ -420,6 +435,7 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + is AppScreen.Cups -> { BreadcrumbSeparator() Text( @@ -427,21 +443,22 @@ private fun BreadcrumbContent( style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), ) } + else -> {} } } // Hilfsfunktion: OEPS-Bundeslandcode → Abkürzung private fun mapOepsToBundesland(code: String): String = when (code.uppercase()) { - "OOE" -> "OÖ" - "NOE" -> "NÖ" - "ST" -> "Stmk." + "OÖ" -> "Oberösterreich" + "NÖ" -> "Niederösterreich" + "ST" -> "Steiermark" "W" -> "Wien" - "BGLD", "B" -> "Bgld." - "K" -> "Ktn." - "S" -> "Sbg." + "B" -> "Burgenland" + "K" -> "Kärnten" + "S" -> "Salzburg" "T" -> "Tirol" - "V" -> "Vbg." + "V" -> "Vorarlberg" else -> code } @@ -621,6 +638,7 @@ private fun DesktopContentArea( onCancel = onBack, onVereinCreated = { newId -> onNavigate(AppScreen.VeranstalterProfil(newId)) } ) + is AppScreen.VeranstalterDetail -> { val vId = currentScreen.veranstalterId if (vId != 1L) { // Temporärer Check für Mock-Daten @@ -637,6 +655,7 @@ private fun DesktopContentArea( ) } } + is AppScreen.VeranstaltungKonfig -> { val vId = currentScreen.veranstalterId // Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard @@ -694,7 +713,8 @@ private fun DesktopContentArea( val v = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { vv -> at.mocode.desktop.v2.StoreV2.eventsFor(vv.id).any { it.id == currentScreen.id } } - val veranstaltung = v?.let { at.mocode.desktop.v2.StoreV2.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } } + val veranstaltung = + v?.let { at.mocode.desktop.v2.StoreV2.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } } val list = at.mocode.desktop.v2.TurnierStoreV2.list(currentScreen.id) val newId = (list.maxOfOrNull { it.id } ?: 0L) + 1L val draft = at.mocode.desktop.v2.TurnierV2( @@ -709,6 +729,7 @@ private fun DesktopContentArea( }, onTurnierOeffnen = { tid -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tid)) }, ) + is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen( onBack = onBack, onSave = { onBack() }, @@ -743,6 +764,7 @@ private fun DesktopContentArea( ) } } + is AppScreen.TurnierNeu -> { val evtId = currentScreen.veranstaltungId // V2: Wir erlauben Turnier-Nr nur, wenn die Veranstaltung im V2-Store existiert @@ -800,11 +822,11 @@ private fun DesktopContentArea( } is AppScreen.Meisterschaften -> { - SeriesScreen(title = "Meisterschaften", onBack = onBack) + SeriesScreen(title = "Meisterschaften", onBack = onBack) } is AppScreen.Cups -> { - SeriesScreen(title = "Cups", onBack = onBack) + SeriesScreen(title = "Cups", onBack = onBack) } is AppScreen.Nennung -> { @@ -832,11 +854,22 @@ private fun DesktopContentArea( } @Composable -private fun DesktopFooterBar() { - // Echte Status-Logik vorbereitet - val online = remember { mutableStateOf(true) } - val deviceConnected = remember { mutableStateOf(true) } - val deviceName = "Richter-Turm" +private fun DesktopFooterBar(settings: OnboardingSettings) { + val connectivityTracker = koinInject() + val discoveryService = koinInject() + + val online by connectivityTracker.isOnline.collectAsState() + val discoveredServices = remember { mutableStateOf(discoveryService.getDiscoveredServices()) } + val deviceName = settings.geraetName.ifBlank { "Unbekannt" } + + // Periodisches Update der LAN-Geräte (mDNS) + LaunchedEffect(Unit) { + discoveryService.startDiscovery() + while (true) { + discoveredServices.value = discoveryService.getDiscoveredServices() + delay(5000.milliseconds) + } + } Surface( color = MaterialTheme.colorScheme.surface, @@ -854,18 +887,19 @@ private fun DesktopFooterBar() { Row(verticalAlignment = Alignment.CenterVertically) { // Status: Cloud Sync StatusIndicator( - icon = if (online.value) Icons.Filled.CloudDone else Icons.Filled.CloudOff, - label = if (online.value) "Cloud synchronisiert" else "Offline (Lokal)", - color = if (online.value) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + icon = if (online) Icons.Filled.CloudDone else Icons.Filled.CloudOff, + label = if (online) "Cloud synchronisiert" else "Offline (Lokal)", + color = if (online) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error ) Spacer(Modifier.width(Dimens.SpacingM)) // Status: LAN Devices (mDNS) + val deviceCount = discoveredServices.value.size StatusIndicator( icon = Icons.Filled.Lan, - label = if (deviceConnected.value) "Verbunden: $deviceName" else "Suche nach Geräten...", - color = if (deviceConnected.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + label = if (deviceCount > 0) "Verbunden: $deviceName ($deviceCount im Netz)" else "Lokal: $deviceName", + color = if (deviceCount > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline ) }