feat: füge ConnectivityTracker hinzu, erweitere networkModule, aktualisiere DesktopFooterBar mit Gerätestatus und mDNS-Discovery

Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-16 00:00:11 +02:00
parent 67d7b38d79
commit 7581f15dfb
3 changed files with 111 additions and 25 deletions
@@ -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<Boolean> = _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()
}
}
@@ -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> { ConnectivityTracker() }
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
single(named("baseHttpClient")) {
HttpClient {
@@ -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."
"" -> "Oberösterreich"
"" -> "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<ConnectivityTracker>()
val discoveryService = koinInject<NetworkDiscoveryService>()
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
)
}