feat(core, device-initialization): Netzwerk-Discovery verbessert, IP-Binding hinzugefügt und UI optimiert

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-30 12:12:46 +02:00
parent 46d993e47f
commit 8ab6ab1c2a
25 changed files with 686 additions and 179 deletions
@@ -68,6 +68,7 @@ sealed class AppScreen(val route: String) {
data object Cups : AppScreen("/cups")
data object StammdatenImport : AppScreen("/stammdaten/import")
data object NennungsEingang : AppScreen("/nennungs-eingang")
data object Chat : AppScreen("/chat")
companion object {
private val EVENT_DETAIL = Regex("/event/(\\d+)$")
@@ -112,6 +113,7 @@ sealed class AppScreen(val route: String) {
"/cups" -> Cups
"/stammdaten/import" -> StammdatenImport
"/nennungs-eingang" -> NennungsEingang
"/chat" -> Chat
else -> {
EVENT_NEU.matchEntire(route)?.let { match ->
val vId = match.groups[2]?.value?.toLong()
@@ -6,10 +6,10 @@ import kotlinx.coroutines.flow.StateFlow
* Modell für einen entdeckten Dienst im lokalen Netzwerk.
*/
data class DiscoveredService(
val name: String,
val host: String,
val port: Int,
val metadata: Map<String, String> = emptyMap()
val name: String,
val host: String,
val port: Int,
val metadata: Map<String, String> = emptyMap()
)
/**
@@ -17,30 +17,32 @@ data class DiscoveredService(
* Erlaubt Offline-First Synchronisation im LAN.
*/
interface NetworkDiscoveryService {
/**
* Ein StateFlow, der die aktuell entdeckten Dienste enthält.
* Ideal für reaktive UIs (Compose).
*/
val discoveredServices: StateFlow<List<DiscoveredService>>
/**
* Ein StateFlow, der die aktuell entdeckten Dienste enthält.
* Ideal für reaktive UIs (Compose).
*/
val discoveredServices: StateFlow<List<DiscoveredService>>
/**
* Startet das Scannen nach verfügbaren Diensten im Netzwerk.
*/
fun startDiscovery()
* Startet das Scannen nach verfügbaren Diensten im Netzwerk.
* @param preferredIp Optional eine IP-Adresse, an die der Discovery-Dienst gebunden werden soll.
*/
fun startDiscovery(preferredIp: String? = null)
/**
* Stoppt den Scan-Vorgang.
*/
fun stopDiscovery()
/**
* Stoppt den Scan-Vorgang.
*/
fun stopDiscovery()
/**
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
*/
fun registerService(port: Int)
/**
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
* @param preferredIp Optional eine IP-Adresse, an die der Discovery-Dienst gebunden werden soll.
*/
fun registerService(port: Int, preferredIp: String? = null)
/**
* Gibt die Liste der aktuell entdeckten Dienste zurück (Snapshot).
*/
fun getDiscoveredServices(): List<DiscoveredService>
/**
* Gibt die Liste der aktuell entdeckten Dienste zurück (Snapshot).
*/
fun getDiscoveredServices(): List<DiscoveredService>
}
@@ -9,49 +9,49 @@ import kotlin.time.Duration.Companion.milliseconds
* Er lauscht auf neu entdeckte Dienste und baut automatisch Verbindungen auf.
*/
class SyncManager(
private val discoveryService: NetworkDiscoveryService,
private val syncService: P2pSyncService
private val discoveryService: NetworkDiscoveryService,
private val syncService: P2pSyncService
) {
private val scope = CoroutineScope(SupervisorJob())
private val knownPeers = mutableSetOf<String>()
private val scope = CoroutineScope(SupervisorJob())
private val knownPeers = mutableSetOf<String>()
fun start(port: Int) {
// Eigenen Dienst registrieren und Server starten
discoveryService.registerService(port)
syncService.startServer(port)
discoveryService.startDiscovery()
fun start(port: Int, preferredIp: String? = null) {
// Eigenen Dienst registrieren und Server starten
discoveryService.registerService(port, preferredIp)
syncService.startServer(port)
discoveryService.startDiscovery(preferredIp)
// Regelmäßig nach neuen Peers suchen und verbinden
scope.launch {
while (isActive) {
val discovered = discoveryService.getDiscoveredServices()
discovered.forEach { service ->
val peerKey = "${service.host}:${service.port}"
if (!knownPeers.contains(peerKey)) {
// TODO: Node-ID Vergleich (Selbst-Verbindung vermeiden)
println("[SyncManager] Neuer Peer entdeckt: $peerKey. Verbinde...")
syncService.connectToPeer(service.host, service.port)
knownPeers.add(peerKey)
}
}
delay(5000.milliseconds) // Alle 5 Sekunden prüfen
}
// Regelmäßig nach neuen Peers suchen und verbinden
scope.launch {
while (isActive) {
val discovered = discoveryService.getDiscoveredServices()
discovered.forEach { service ->
val peerKey = "${service.host}:${service.port}"
if (!knownPeers.contains(peerKey)) {
// TODO: Node-ID Vergleich (Selbst-Verbindung vermeiden)
println("[SyncManager] Neuer Peer entdeckt: $peerKey. Verbinde...")
syncService.connectToPeer(service.host, service.port)
knownPeers.add(peerKey)
}
}
delay(5000.milliseconds) // Alle 5 Sekunden prüfen
}
}
}
fun getConnectedPeers() = syncService.connectedPeers
fun getConnectedPeers() = syncService.connectedPeers
fun broadcastEvent(event: SyncEvent) {
scope.launch {
syncService.broadcastEvent(event)
}
fun broadcastEvent(event: SyncEvent) {
scope.launch {
syncService.broadcastEvent(event)
}
}
fun getIncomingEvents() = syncService.incomingEvents
fun getIncomingEvents() = syncService.incomingEvents
fun stop() {
scope.cancel()
discoveryService.stopDiscovery()
syncService.stopServer()
}
fun stop() {
scope.cancel()
discoveryService.stopDiscovery()
syncService.stopServer()
}
}
@@ -15,64 +15,71 @@ import javax.jmdns.ServiceListener
*/
class JmDnsDiscoveryService : NetworkDiscoveryService {
private var jmdns: JmDNS? = null
private var jmdns: JmDNS? = null
private val SERVICE_TYPE = "_meldestelle._tcp.local."
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
override fun startDiscovery() {
if (jmdns == null) {
jmdns = JmDNS.create(InetAddress.getLocalHost())
}
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
// Bei ServiceAdded fordern wir die Details an
jmdns?.requestServiceInfo(event.type, event.name)
}
override fun serviceRemoved(event: ServiceEvent) {
discoveredServicesMap.remove(event.name)
_discoveredServices.value = discoveredServicesMap.values.toList()
println("[Discovery] Service entfernt: ${event.name}")
}
override fun serviceResolved(event: ServiceEvent) {
val info = event.info
val service = DiscoveredService(
name = event.name,
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
port = info.port,
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
)
discoveredServicesMap[event.name] = service
_discoveredServices.value = discoveredServicesMap.values.toList()
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
}
})
override fun startDiscovery(preferredIp: String?) {
if (jmdns == null) {
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
println("[Discovery] Starte Discovery gebunden an: $addr")
jmdns = JmDNS.create(addr)
}
override fun stopDiscovery() {
jmdns?.close()
jmdns = null
discoveredServicesMap.clear()
_discoveredServices.value = emptyList()
}
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
// Bei ServiceAdded fordern wir die Details an
jmdns?.requestServiceInfo(event.type, event.name)
}
override fun registerService(port: Int) {
val serviceInfo = ServiceInfo.create(
SERVICE_TYPE,
"Meldestelle-${System.getProperty("user.name")}",
port,
"Offline-First Sync Node"
override fun serviceRemoved(event: ServiceEvent) {
discoveredServicesMap.remove(event.name)
_discoveredServices.value = discoveredServicesMap.values.toList()
println("[Discovery] Service entfernt: ${event.name}")
}
override fun serviceResolved(event: ServiceEvent) {
val info = event.info
val service = DiscoveredService(
name = event.name,
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
port = info.port,
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
)
jmdns?.registerService(serviceInfo)
println("[Discovery] Eigenen Dienst registriert auf Port $port")
}
discoveredServicesMap[event.name] = service
_discoveredServices.value = discoveredServicesMap.values.toList()
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
}
})
}
override fun getDiscoveredServices(): List<DiscoveredService> {
return discoveredServicesMap.values.toList()
override fun stopDiscovery() {
jmdns?.close()
jmdns = null
discoveredServicesMap.clear()
_discoveredServices.value = emptyList()
}
override fun registerService(port: Int, preferredIp: String?) {
if (jmdns == null) {
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
println("[Discovery] Registriere Dienst gebunden an: $addr")
jmdns = JmDNS.create(addr)
}
val serviceInfo = ServiceInfo.create(
SERVICE_TYPE,
"Meldestelle-${System.getProperty("user.name")}",
port,
"Offline-First Sync Node"
)
jmdns?.registerService(serviceInfo)
println("[Discovery] Eigenen Dienst registriert auf Port $port")
}
override fun getDiscoveredServices(): List<DiscoveredService> {
return discoveredServicesMap.values.toList()
}
}
@@ -10,14 +10,15 @@ import org.koin.dsl.module
* Wasm-spezifische Implementierung (vorerst No-op).
*/
actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { NoOpDiscoveryService() }
single<NetworkDiscoveryService> { NoOpDiscoveryService() }
}
class NoOpDiscoveryService : NetworkDiscoveryService {
override val discoveredServices: StateFlow<List<DiscoveredService>> =
MutableStateFlow<List<DiscoveredService>>(emptyList()).asStateFlow()
override fun startDiscovery() {}
override fun stopDiscovery() {}
override fun registerService(port: Int) {}
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
override fun startDiscovery(preferredIp: String?) {}
override fun stopDiscovery() {}
override fun registerService(port: Int, preferredIp: String?) {}
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
}
@@ -144,7 +144,7 @@ fun DeviceInitializationScreen(
onClick = { viewModel.updateSettings { it.copy(appTheme = theme) } },
label = {
Text(
when(theme) {
when (theme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> "System"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> "Hell"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> "Dunkel"
@@ -179,13 +179,23 @@ fun DeviceInitializationScreen(
if (!uiState.isLocked) {
val role = uiState.settings.networkRole
val hasDiscoveries = uiState.discoveredMasters.isNotEmpty()
val selectedInterface = uiState.settings.networkInterface
LaunchedEffect(selectedInterface, role) {
if (selectedInterface.isNotEmpty()) {
viewModel.startDiscovery()
}
}
Surface(
color = if (hasDiscoveries) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.15f)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.medium,
border = if (hasDiscoveries) androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
else null
border = if (hasDiscoveries) androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
)
else null
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
@@ -203,7 +213,7 @@ fun DeviceInitializationScreen(
},
style = MaterialTheme.typography.bodySmall,
color = if (hasDiscoveries) MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
}
@@ -22,16 +22,20 @@ class DeviceInitializationViewModel(
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
private val _initializationCompleteEvent = MutableSharedFlow<DeviceInitializationSettings>()
val initializationCompleteEvent: SharedFlow<DeviceInitializationSettings> = _initializationCompleteEvent.asSharedFlow()
val initializationCompleteEvent: SharedFlow<DeviceInitializationSettings> =
_initializationCompleteEvent.asSharedFlow()
init {
val existingSettings = at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager.loadSettings()
val existingSettings =
at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager.loadSettings()
if (existingSettings != null) {
println("[DeviceInit] Bestehende Einstellungen geladen.")
_uiState.update { it.copy(
settings = existingSettings,
isLocked = existingSettings.isConfigured
) }
_uiState.update {
it.copy(
settings = existingSettings,
isLocked = existingSettings.isConfigured
)
}
}
viewModelScope.launch {
@@ -43,7 +47,20 @@ class DeviceInitializationViewModel(
}
fun startDiscovery() {
discoveryService.startDiscovery()
val selectedInterface = uiState.value.settings.networkInterface
val ip = if (selectedInterface.contains("(") && selectedInterface.contains(")")) {
selectedInterface.substringAfter("(").substringBefore(")")
} else {
null
}
println("[DeviceInit] Starte/Restart Discovery für IP: $ip (Interface: $selectedInterface)")
discoveryService.stopDiscovery()
discoveryService.startDiscovery(ip)
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
discoveryService.registerService(8080, ip)
}
}
@@ -2,10 +2,13 @@
package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.outlined.Visibility
@@ -22,6 +25,7 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component5
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
@@ -82,32 +86,94 @@ actual fun DeviceInitializationConfig(
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() }
.map { ni ->
val friendlyName = when {
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains("wi-fi", ignoreCase = true) -> "WLAN"
ni.displayName.contains("eth", ignoreCase = true) || ni.displayName.contains("ethernet", ignoreCase = true) -> "Ethernet"
else -> ni.displayName
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains(
"wi-fi",
ignoreCase = true
) || ni.name.contains("wlan", ignoreCase = true) -> "🌐 WLAN"
ni.displayName.contains("eth", ignoreCase = true) || ni.displayName.contains(
"ethernet",
ignoreCase = true
) || ni.name.contains("eth", ignoreCase = true) || ni.name.contains(
"en",
ignoreCase = true
) -> "🔌 Ethernet"
else -> "💻 " + ni.displayName
}
val address = ni.inetAddresses.asSequence()
.filter { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 } // Nur IPv4, keine Link-Local
.firstOrNull()?.hostAddress ?: ni.inetAddresses.nextElement().hostAddress
"$friendlyName ($address)"
val address =
ni.inetAddresses.asSequence()
.firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 }?.hostAddress
?: ni.inetAddresses.nextElement().hostAddress
val isConnected = !ni.isLoopback && ni.isUp && ni.interfaceAddresses.any {
it.address.isSiteLocalAddress || it.address.hostAddress.startsWith("192.168") || it.address.hostAddress.startsWith(
"10."
)
}
InterfaceInfo(
id = "$friendlyName ($address)",
name = friendlyName,
address = address,
hardwareName = ni.name,
isConnected = isConnected
)
}
}
LaunchedEffect(interfaces) {
if (settings.networkInterface.isEmpty() && interfaces.isNotEmpty()) {
viewModel.updateSettings { s -> s.copy(networkInterface = interfaces.first()) }
val bestMatch = interfaces.find { it.isConnected } ?: interfaces.first()
viewModel.updateSettings { s -> s.copy(networkInterface = bestMatch.id) }
}
}
MsStringDropdown(
label = "Netzwerk-Interface",
helpDescription = "Wähle das Netzwerk-Interface aus, über das die App kommunizieren soll (z.B. LAN für das Turnier-Netzwerk).",
options = interfaces,
selectedOption = settings.networkInterface,
onOptionSelected = { viewModel.updateSettings { s -> s.copy(networkInterface = it) } },
placeholder = "Interface wählen...",
enabled = !uiState.isLocked
)
Text("🌐 Netzwerk-Interface", style = MaterialTheme.typography.titleSmall)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
interfaces.forEach { info ->
val isSelected = settings.networkInterface == info.id
Surface(
onClick = { if (!uiState.isLocked) viewModel.updateSettings { s -> s.copy(networkInterface = info.id) } },
shape = MaterialTheme.shapes.medium,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
),
border = if (isSelected) androidx.compose.foundation.BorderStroke(
2.dp,
MaterialTheme.colorScheme.primary
) else null,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(12.dp)
.background(
color = if (info.isConnected) Color(0xFF4CAF50) else Color(0xFFF44336),
shape = CircleShape
)
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(info.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
Text("IP: ${info.address} (${info.hardwareName})", style = MaterialTheme.typography.bodySmall)
}
if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
}
}
}
}
}
if (interfaces.isEmpty()) {
Text("⚠️ Kein aktives Netzwerk-Interface gefunden!", color = MaterialTheme.colorScheme.error)
}
var passwordVisible by remember { mutableStateOf(false) }
MsTextField(
@@ -143,30 +209,30 @@ actual fun DeviceInitializationConfig(
)
if (!uiState.isLocked && settings.backupPath.isNotBlank() && settings.sharedKey.isNotBlank()) {
OutlinedButton(
onClick = { viewModel.testUsbBackup() },
modifier = Modifier.padding(top = 4.dp).align(Alignment.End),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(Icons.Default.Usb, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Plan-USB Test-Export", style = MaterialTheme.typography.labelLarge)
}
OutlinedButton(
onClick = { viewModel.testUsbBackup() },
modifier = Modifier.padding(top = 4.dp).align(Alignment.End),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(Icons.Default.Usb, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Plan-USB Test-Export", style = MaterialTheme.typography.labelLarge)
}
}
val printers = remember {
val systemPrinters = PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.toMutableList()
if (!systemPrinters.contains("PDF-Export (Lokal)")) {
systemPrinters.add(0, "PDF-Export (Lokal)")
}
systemPrinters.sortedBy { it != "PDF-Export (Lokal)" } // PDF immer oben
val printers = remember {
val systemPrinters = PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.toMutableList()
if (!systemPrinters.contains("PDF-Export (Lokal)")) {
systemPrinters.add(0, "PDF-Export (Lokal)")
}
systemPrinters.sortedBy { it != "PDF-Export (Lokal)" } // PDF immer oben
}
LaunchedEffect(printers) {
if (settings.defaultPrinter.isEmpty() && printers.isNotEmpty()) {
viewModel.updateSettings { s -> s.copy(defaultPrinter = printers.first()) }
}
LaunchedEffect(printers) {
if (settings.defaultPrinter.isEmpty() && printers.isNotEmpty()) {
viewModel.updateSettings { s -> s.copy(defaultPrinter = printers.first()) }
}
}
MsStringDropdown(
label = "Standard-Drucker",
@@ -303,7 +369,7 @@ actual fun DeviceInitializationConfig(
Text("Client hinzufügen")
}
}
} else if (settings.networkRole != NetworkRole.MASTER && !uiState.isLocked) {
} else if (settings.networkRole != NetworkRole.MASTER && !uiState.isLocked) {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall)
@@ -366,6 +432,14 @@ actual fun DeviceInitializationConfig(
}
}
private data class InterfaceInfo(
val id: String,
val name: String,
val address: String,
val hardwareName: String,
val isConnected: Boolean
)
@Composable
private fun ClientEntryRow(
name: String,
@@ -90,7 +90,8 @@ fun DesktopApp() {
currentScreen is AppScreen.ConnectivityCheck ||
currentScreen is AppScreen.Dashboard ||
currentScreen is AppScreen.Profile ||
currentScreen is AppScreen.ProfileOnboarding
currentScreen is AppScreen.ProfileOnboarding ||
currentScreen is AppScreen.Chat
if (!authState.isAuthenticated && !isAllowedScreen) {
LaunchedEffect(currentScreen) {
@@ -0,0 +1,167 @@
package at.mocode.frontend.shell.desktop.screens.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
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 java.time.LocalTime
import java.time.format.DateTimeFormatter
data class ChatMessage(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
@Composable
fun ChatScreen(
onBack: () -> Unit
) {
var messageText by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<ChatMessage>() }
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
// Mock initial messages
LaunchedEffect(Unit) {
if (messages.isEmpty()) {
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false))
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
}
}
Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) {
// Header
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Veranstaltungs-Chat",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"LAN-Kanal: aktiv (3 Teilnehmer)",
style = MaterialTheme.typography.labelMedium,
color = AppColors.Success
)
}
}
}
// Chat Messages
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
contentPadding = PaddingValues(vertical = Dimens.SpacingM),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
items(messages) { msg ->
ChatBubble(msg)
}
}
// Input Area
Surface(
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
OutlinedTextField(
value = messageText,
onValueChange = { messageText = it },
placeholder = { Text("Nachricht schreiben...") },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(24.dp),
maxLines = 3
)
IconButton(
onClick = {
if (messageText.isNotBlank()) {
messages.add(
ChatMessage(
id = messages.size.toString(),
sender = "Meldestelle",
text = messageText,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
)
messageText = ""
}
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
modifier = Modifier.size(48.dp)
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden")
}
}
}
}
}
@Composable
private fun ChatBubble(msg: ChatMessage) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start
) {
if (!msg.isFromMe) {
Text(
msg.sender,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
)
}
Surface(
color = if (msg.isFromMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
bottomStart = if (msg.isFromMe) 12.dp else 0.dp,
bottomEnd = if (msg.isFromMe) 0.dp else 12.dp
),
modifier = Modifier.widthIn(max = 400.dp)
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(msg.text, style = MaterialTheme.typography.bodyMedium)
Text(
msg.time,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
),
modifier = Modifier.align(Alignment.End)
)
}
}
}
}
@@ -86,7 +86,8 @@ fun DesktopMainLayout(
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
DesktopFooterBar(
settings = onboardingSettings,
onSetupClick = { onNavigate(AppScreen.DeviceInitialization) }
onSetupClick = { onNavigate(AppScreen.DeviceInitialization) },
onNavigate = onNavigate
)
}
}
@@ -42,6 +42,7 @@ import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfig
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.chat.ChatScreen
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
@@ -341,6 +342,12 @@ fun DesktopContentArea(
)
}
is AppScreen.Chat -> {
ChatScreen(
onBack = onBack
)
}
is AppScreen.EntryManagement -> {
val viewModel = koinViewModel<NennungViewModel>()
NennungManagementScreen(viewModel = viewModel)
@@ -3,6 +3,7 @@ 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.automirrored.filled.Chat
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Dataset
@@ -18,6 +19,7 @@ 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.navigation.AppScreen
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
@@ -28,7 +30,8 @@ import kotlin.time.Duration.Companion.milliseconds
@Composable
fun DesktopFooterBar(
settings: DeviceInitializationSettings,
onSetupClick: () -> Unit = {}
onSetupClick: () -> Unit = {},
onNavigate: (AppScreen) -> Unit = {}
) {
val connectivityTracker = koinInject<ConnectivityTracker>()
val discoveryService = koinInject<NetworkDiscoveryService>()
@@ -102,7 +105,26 @@ fun DesktopFooterBar(
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
// Chat Trigger
Button(
onClick = { onNavigate(AppScreen.Chat) },
contentPadding = PaddingValues(horizontal = Dimens.SpacingS, vertical = 0.dp),
modifier = Modifier.height(22.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = MaterialTheme.shapes.small
) {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, modifier = Modifier.size(12.dp))
Spacer(Modifier.width(4.dp))
Text("Chat", style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp))
}
Text(
text = "v2.4.0-rc1 | Desktop-Alpha",
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -180,6 +180,46 @@ fun Erfolgsscreen(email: String, onBack: () -> Unit) {
}
}
@Composable
fun DownloadDesktopAppCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = AppColors.PrimaryContainer),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Meldestelle Desktop",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = AppColors.OnPrimaryContainer
)
Text(
"Laden Sie die professionelle Desktop-App für die Offline-Verwaltung Ihres Turniers herunter.",
style = MaterialTheme.typography.bodyLarge,
color = AppColors.OnPrimaryContainer.copy(alpha = 0.8f),
modifier = Modifier.padding(top = 8.dp)
)
}
Button(
onClick = { /* In POC: Zeigt Hinweis oder simuliert Download */ },
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Primary),
modifier = Modifier.height(56.dp)
) {
Icon(Icons.Default.Description, contentDescription = null) // Verwende Description als Ersatz für Download
Spacer(Modifier.width(12.dp))
Text("Desktop-App laden", style = MaterialTheme.typography.titleMedium)
}
}
}
}
@Composable
fun LandingPage(
onVeranstaltungClick: (Long) -> Unit,
@@ -205,6 +245,10 @@ fun LandingPage(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
item {
DownloadDesktopAppCard()
}
item {
Text(
"Willkommen bei der Meldestelle Online",
@@ -23,7 +23,8 @@ fun main() {
}
ComposeViewport("compose-target") {
AppTheme {
// Web-Shell wird hart auf Light-Mode gesetzt (Ablesbarkeit am Turnierplatz)
AppTheme(darkTheme = false) {
WebMainScreen()
}
}