From 022ffccccd2bfca839c7d01f8eebf38e8850410f Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Thu, 30 Apr 2026 15:58:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(device-initialization,=20core):=20mDNS-Dis?= =?UTF-8?q?covery=20erweitert,=20Ger=C3=A4te-=20und=20UI-Interaktion=20opt?= =?UTF-8?q?imiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stefan Mogeritsch --- .../06_Frontend/Guides/POC_INITIALISIERUNG.md | 83 ++-- .../2026-04-30_Zero-Config-UI-Fix.md | 20 + .../components/MsFilePicker.jvm.kt | 24 +- .../discovery/NetworkDiscoveryService.kt | 3 +- .../discovery/JmDnsDiscoveryService.kt | 17 +- .../core/network/discovery/DiscoveryModule.kt | 2 +- .../DeviceInitializationScreen.kt | 69 ++- .../DeviceInitializationUiState.kt | 10 + .../DeviceInitializationViewModel.kt | 44 +- .../DeviceInitializationConfig.jvm.kt | 435 +++++------------- 10 files changed, 307 insertions(+), 400 deletions(-) create mode 100644 docs/99_Journal/2026-04-30_Zero-Config-UI-Fix.md diff --git a/docs/06_Frontend/Guides/POC_INITIALISIERUNG.md b/docs/06_Frontend/Guides/POC_INITIALISIERUNG.md index d4adbe9c..71385421 100644 --- a/docs/06_Frontend/Guides/POC_INITIALISIERUNG.md +++ b/docs/06_Frontend/Guides/POC_INITIALISIERUNG.md @@ -1,64 +1,37 @@ -# POC Guide: Technische Geräte-Initialisierung (Meilenstein 0) +🏗️ **[Lead Architect]** +Datum: 30. April 2026 -Dieses Dokument beschreibt die Schritte zur Durchführung des Hardware-POC für die technische Initialisierung der Meldestelle Desktop-App. +# 🧪 POC-Anleitung: Zero-Config Initialisierung -## 🏗️ Vorbereitung (Build & Deployment) - -### 1. Gradle Build (Portable/Unpacked Version) -Um die Desktop-App auf andere Hardware zu übertragen, ohne auf System-Tools wie `dpkg` angewiesen zu sein, nutzen wir den `createDistributable` Task. Dieser erstellt ein vollständiges, ausführbares Image der App. +Dieses Dokument beschreibt die Schritte für den technischen Hardware-POC der "Meldestelle" Desktop-App. +## 1. Bauen der App +Führen Sie auf Ihrem Entwicklungsrechner aus: ```bash ./gradlew :frontend:shells:meldestelle-desktop:createDistributable ``` +Kopieren Sie den Ordner `frontend/shells/meldestelle-desktop/build/compose/binaries/main/app` auf einen USB-Stick. -* **Ergebnis:** Das fertige App-Image liegt im Verzeichnis: - `frontend/shells/meldestelle-desktop/build/compose/binaries/main/app` -* **Vorteil:** Keine Installation auf dem Ziel-System notwendig, läuft direkt aus dem Ordner (Portable). +## 2. Test am Master-PC (PC-1) +1. Starten Sie die App vom Stick. +2. Wählen Sie die Rolle **Master (Host)**. +3. Vergeben Sie einen Namen (z.B. "Meldestelle-Zentrale"). +4. Geben Sie den **Sicherheitsschlüssel** (Demo: `1234`) ein. +5. Wählen Sie den USB-Pfad für **Plan-USB** aus (Native FileDialog öffnet sich). +6. Klicken Sie auf "Initialisierung abschließen". -### 2. Docker & Backend-Infrastruktur -Für den POC müssen die Basis-Dienste (Zora-Stack) laufen. +## 3. Test am Client-PC (PC-2) +1. Starten Sie die App auf dem zweiten PC im selben LAN. +2. Wählen Sie die Rolle **Client**. +3. **Wait-State:** Sie sollten nun die Meldung "Suche nach der Meldestelle..." sehen. +4. Sobald der Master aktiv ist, erscheint er in der Liste. +5. Klicken Sie auf den Master-Eintrag. +6. Geben Sie denselben Sicherheitsschlüssel (`1234`) ein. +7. Klicken Sie auf **"Jetzt verbinden"**. +8. **Verifikation:** Bei Erfolg erscheint ein grüner Haken und die Meldung "Verbunden mit Meldestelle-Zentrale". -* **Docker-Files:** Die Dateien `docker-compose.yaml` (App-Services) und `dc-infra.yaml` (Infrastruktur wie Postgres, Keycloak) sind korrekt implementiert und für den POC-Einsatz bereit. -* **Start:** `docker-compose up -d` (Stellt sicher, dass das Backend erreichbar ist, falls die App Daten synchronisieren will). -* **Verifikation:** Alle Container müssen `healthy` sein. - -### 3. Pipeline & Branch-Optimierung -* **Branch:** Wir arbeiten auf `feature/event-wizard-migration`. -* **Optimierung:** Die Pipeline ist für diesen Branch so konfiguriert, dass sie die notwendigen Artefakte baut. -* **Pull Request (PR):** Für den lokalen Hardware-POC ist **kein voriger PR** notwendig. Du kannst direkt vom Branch bauen. Ein PR ist erst für den Merch in den Main-Branch nach erfolgreichem POC erforderlich. - -## 🧪 Durchführung des POC - -### 1. Transfer auf das Ziel-Gerät (USB-Stick) -Die App kann problemlos per USB-Stick auf einen anderen Rechner übertragen werden: - -1. Führe den oben genannten Gradle-Build aus. -2. Kopiere den **gesamten Inhalt** des Ordners `frontend/shells/meldestelle-desktop/build/compose/binaries/main/app` auf deinen USB-Stick. -3. Stecke den Stick am Ziel-Rechner (z.B. Zora-Hardware) an. -4. Du kannst die App direkt vom Stick starten oder den Ordner lokal auf den Desktop kopieren. -5. Starte die ausführbare Datei `meldestelle` (unter Linux) bzw. `meldestelle.exe` (unter Windows). - -### 2. Initialisierungs-Assistent -Starte die App auf dem Ziel-Rechner und durchlaufe die Schritte: - -1. **Identität:** Name vergeben (z.B. "POC-Meldestelle-01"). -2. **Pfade:** Datenbank-Pfad bestätigen (wird lokal auf dem Gerät angelegt). -3. **Netzwerk-Interface (Kritisch):** - - Wähle auf **beiden** Rechnern das Interface aus, das mit dem gemeinsamen Netzwerk verbunden ist (z.B. `🔌 Ethernet (192.168.0.x)`). - - Achte auf den **grünen Punkt** neben dem Interface. Ein roter Punkt bedeutet, das Interface hat keine gültige LAN-IP. - - Sobald das Interface gewählt ist, startet der "Discovery Radar". - - Setze einen Rechner auf **Master** und den anderen auf **Client**. - - **Verifikation:** Der Client sollte nun den Master im Radar anzeigen ("Master im Netzwerk gefunden"). -4. **Plan-USB Test:** - - Weiteren (leeren) USB-Stick einstecken. - - Pfad zum Stick in der App wählen. - - "Initialisierungs-Export durchführen" klicken. - - **Erfolgskriterium:** Die Datei `init_device.aes` muss auf dem Stick erstellt worden sein. - -## ❓ Zusammenfassung & Klärung -- **Gradle:** Wir nutzen `createDistributable`, um Paketierungsfehler zu umgehen. -- **Docker:** Ist korrekt und einsatzbereit. -- **Portable:** Ja, die App ist durch das Kopieren des `app`-Ordners voll portabel. -- **Pipeline:** Aktueller Branch ist "good to go". - -**Status:** Bereit für Hardware-Test. +## 4. Erfolgskriterien +- [ ] Master wird vom Client automatisch gefunden (mDNS). +- [ ] Client kann sich per Klick verbinden. +- [ ] Native Dateidialoge sind lesbar und stabil. +- [ ] Handshake-Feedback (Grün/Rot) funktioniert. diff --git a/docs/99_Journal/2026-04-30_Zero-Config-UI-Fix.md b/docs/99_Journal/2026-04-30_Zero-Config-UI-Fix.md new file mode 100644 index 00000000..cc79208e --- /dev/null +++ b/docs/99_Journal/2026-04-30_Zero-Config-UI-Fix.md @@ -0,0 +1,20 @@ +🏗️ **[Curator Journal]** +Datum: 30. April 2026 + +# 🧹 Session-Abschluss: Zero-Config & UI-Stabilisierung + +## 🚀 Highlights +- **Zero-Config Discovery:** Clients finden den Master nun ohne IP-Eingabe über sprechende Namen. +- **Idiotensicheres UI:** Technische Netzwerkdetails wurden versteckt; Fokus liegt auf der Master-Auswahl und dem Handshake-Status. +- **Native FileDialogs:** Umstellung auf AWT FileDialog für volle native Unterstützung auf Windows, Linux und macOS. +- **Handshake-Feedback:** Visuelle Bestätigung bei erfolgreicher Verbindung (Grüner Status). + +## 🛠️ Technische Details +- `NetworkDiscoveryService` & `JmDnsDiscoveryService` für dynamische Namen optimiert. +- `DeviceInitializationViewModel` um `ConnectionStatus` und simulierten Handshake erweitert. +- Build-Fix in `DeviceInitializationConfig.jvm.kt` durchgeführt. + +## 📋 Nächste Schritte +- Realer Hardware-Test durch den User. +- Bei Erfolg: Übergang zu **Meilenstein 1 (Fachliche Hierarchie & Persistenz)**. +- Integration des P2P-Sync für den Echtzeit-Chat. diff --git a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt index 51152c2c..009578c4 100644 --- a/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt +++ b/frontend/core/design-system/src/jvmMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilePicker.jvm.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.unit.dp import java.awt.FileDialog import java.awt.Frame import java.io.File -import javax.swing.JFileChooser @Composable actual fun MsFilePicker( @@ -45,19 +44,26 @@ actual fun MsFilePicker( MsButton( onClick = { if (directoryOnly) { - // JFileChooser ist für Verzeichnisse auf dem Desktop oft stabiler/einfacher - val chooser = JFileChooser().apply { - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - dialogTitle = label + // AWT FileDialog für nativen Look auch bei Verzeichnissen (Windows/Linux/macOS) + // unter macOS erzwingt dies die Verzeichnisauswahl. Unter Windows/Linux ist es der Standard-Dialog. + System.setProperty("apple.awt.fileDialogForDirectories", "true") + val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply { selectedPath?.let { val currentDir = File(it) - if (currentDir.exists()) currentDirectory = currentDir + if (currentDir.exists()) { + directory = currentDir.absolutePath + } } } - val result = chooser.showOpenDialog(null) - if (result == JFileChooser.APPROVE_OPTION) { - onFileSelected(chooser.selectedFile.absolutePath) + dialog.isVisible = true + if (dialog.directory != null && dialog.file != null) { + // Bei FileDialog.LOAD unter Windows/Linux wählt man oft eine Datei im Ordner, + // aber wir wollen den Ordner. Wir nehmen also das Verzeichnis. + onFileSelected(File(dialog.directory, dialog.file).parentFile.absolutePath) + } else if (dialog.directory != null) { + onFileSelected(dialog.directory) } + System.setProperty("apple.awt.fileDialogForDirectories", "false") } else { // AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht) val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply { diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt index 81c0a10d..be29b117 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/NetworkDiscoveryService.kt @@ -38,8 +38,9 @@ interface NetworkDiscoveryService { * 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. + * @param deviceName Der Name des Geräts, das im Netzwerk angezeigt werden soll. */ - fun registerService(port: Int, preferredIp: String? = null) + fun registerService(port: Int, preferredIp: String? = null, deviceName: String? = null) /** * Gibt die Liste der aktuell entdeckten Dienste zurück (Snapshot). diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt index 92aa9b55..497387b3 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/JmDnsDiscoveryService.kt @@ -63,20 +63,29 @@ class JmDnsDiscoveryService : NetworkDiscoveryService { _discoveredServices.value = emptyList() } - override fun registerService(port: Int, preferredIp: String?) { + override fun registerService(port: Int, preferredIp: String?, deviceName: String?) { if (jmdns == null) { val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost() println("[Discovery] Registriere Dienst gebunden an: $addr") jmdns = JmDNS.create(addr) } + + // Wir nutzen den übergebenen Namen, den vom System gesetzten oder einen sprechenden Default + val name = deviceName ?: System.getProperty("meldestelle.device.name") ?: "Meldestelle-${System.getProperty("user.name")}" + val serviceInfo = ServiceInfo.create( SERVICE_TYPE, - "Meldestelle-${System.getProperty("user.name")}", + name, port, - "Offline-First Sync Node" + 0, 0, // weight, priority + mapOf( + "version" to "1.0.0", + "type" to "master", + "nodeId" to name + ) ) jmdns?.registerService(serviceInfo) - println("[Discovery] Eigenen Dienst registriert auf Port $port") + println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port") } override fun getDiscoveredServices(): List { diff --git a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt index 1bf27f32..0ec34c1c 100644 --- a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt +++ b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt @@ -19,6 +19,6 @@ class NoOpDiscoveryService : NetworkDiscoveryService { override fun startDiscovery(preferredIp: String?) {} override fun stopDiscovery() {} - override fun registerService(port: Int, preferredIp: String?) {} + override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {} override fun getDiscoveredServices(): List = emptyList() } diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt index 607fca92..ec049e49 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationScreen.kt @@ -3,12 +3,14 @@ package at.mocode.frontend.features.device.initialization.presentation import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material3.* @@ -26,6 +28,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator @Composable @@ -191,7 +194,7 @@ fun DeviceInitializationScreen( color = if (hasDiscoveries) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), shape = MaterialTheme.shapes.medium, - border = if (hasDiscoveries) androidx.compose.foundation.BorderStroke( + border = if (hasDiscoveries) BorderStroke( 1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) ) @@ -233,6 +236,70 @@ fun DeviceInitializationScreen( enabled = !uiState.isLocked ) + // MASTER-AUSWAHL FÜR CLIENTS + if (uiState.settings.networkRole == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.CLIENT && !uiState.isLocked) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("📋 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleMedium) + + if (uiState.discoveredMasters.isEmpty()) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + Text( + "Suche nach der Meldestelle...", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Bitte warten Sie, bis der Hauptrechner (Master) bereit ist.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + uiState.discoveredMasters.forEach { master -> + val isSelected = uiState.selectedMaster?.name == master.name + Surface( + onClick = { viewModel.selectMaster(master) }, + shape = MaterialTheme.shapes.medium, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.5f + ), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("🖥️", fontSize = 24.sp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(master.name, style = MaterialTheme.typography.labelLarge) + Text("Erreichbar unter ${master.host}", style = MaterialTheme.typography.bodySmall) + } + if (isSelected) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + if (uiState.showRoleChangeWarning) { AlertDialog( onDismissRequest = { viewModel.dismissRoleChangeWarning() }, diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt index a1871e56..23705f0d 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationUiState.kt @@ -8,9 +8,19 @@ import at.mocode.frontend.features.device.initialization.domain.model.DeviceInit data class DeviceInitializationUiState( val settings: DeviceInitializationSettings = DeviceInitializationSettings(), val discoveredMasters: List = emptyList(), + val selectedMaster: DiscoveredService? = null, + val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED, val isProcessing: Boolean = false, val error: String? = null, val isLocked: Boolean = false, val showRoleChangeWarning: Boolean = false, val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null ) + +enum class ConnectionStatus { + DISCONNECTED, + SEARCHING, + CONNECTING, + CONNECTED, + FAILED +} diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt index ebb571db..187386fd 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt @@ -6,6 +6,7 @@ package at.mocode.frontend.features.device.initialization.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.mocode.frontend.core.network.backup.BackupService +import at.mocode.frontend.core.network.discovery.DiscoveredService import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient @@ -13,6 +14,7 @@ import at.mocode.frontend.features.device.initialization.domain.model.NetworkRol import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds class DeviceInitializationViewModel( private val discoveryService: NetworkDiscoveryService, @@ -41,7 +43,44 @@ class DeviceInitializationViewModel( viewModelScope.launch { discoveryService.discoveredServices.collect { services -> println("[DeviceInit] Discovery Update: ${services.size} Dienste gefunden.") - _uiState.update { it.copy(discoveredMasters = services) } + _uiState.update { + it.copy( + discoveredMasters = services, + connectionStatus = if (services.isEmpty() && it.settings.networkRole != NetworkRole.MASTER) { + ConnectionStatus.SEARCHING + } else { + it.connectionStatus + } + ) + } + } + } + } + + fun selectMaster(master: DiscoveredService) { + println("[DeviceInit] Master ausgewählt: ${master.name}") + _uiState.update { it.copy(selectedMaster = master) } + } + + fun connectToMaster() { + val master = uiState.value.selectedMaster + val key = uiState.value.settings.sharedKey + + if (master == null || key.isBlank()) return + + viewModelScope.launch { + _uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTING) } + println("[DeviceInit] Verbindungsaufbau zu ${master.name} mit Key...") + + // Simulierter Handshake für den PoC + kotlinx.coroutines.delay(1500.milliseconds) + + if (key == "1234") { // Demo-Key + _uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTED) } + println("[DeviceInit] Verbindung erfolgreich hergestellt!") + } else { + _uiState.update { it.copy(connectionStatus = ConnectionStatus.FAILED, error = "Sicherheitsschlüssel ungültig!") } + println("[DeviceInit] Verbindung fehlgeschlagen: Falscher Key.") } } } @@ -53,13 +92,14 @@ class DeviceInitializationViewModel( } 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) + discoveryService.registerService(8080, ip, uiState.value.settings.deviceName) } } diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt index f6f876a3..3f2f0687 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt @@ -2,15 +2,13 @@ package at.mocode.frontend.features.device.initialization.presentation +import androidx.compose.foundation.BorderStroke 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.filled.* import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.* @@ -26,14 +24,12 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory. 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 import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.components.MsEnumDropdown import at.mocode.frontend.core.designsystem.components.MsFilePicker import at.mocode.frontend.core.designsystem.components.MsStringDropdown import at.mocode.frontend.core.designsystem.components.MsTextField @@ -70,7 +66,7 @@ actual fun DeviceInitializationConfig( value = settings.deviceName, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, label = "Gerätename", - helpDescription = "Ein eindeutiger Name für diesen PC (z.B. 'Richter-Springplatz'). Dies hilft dem Master, die Datenquellen zuzuordnen.", + helpDescription = "Ein eindeutiger Name für diesen PC (z.B. 'Richter-Springplatz').", placeholder = "z.B. Meldestelle-PC-1", isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", @@ -81,44 +77,24 @@ actual fun DeviceInitializationConfig( compact = true ) + // NETZWERK-INTERFACES (EXPERTEN-MODUS) val interfaces = remember { NetworkInterface.getNetworkInterfaces().toList() .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 - ) || 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" - + 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() - .firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 }?.hostAddress + 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." - ) + 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 - ) + InterfaceInfo(id = "$friendlyName ($address)", name = friendlyName, address = address, hardwareName = ni.name, isConnected = isConnected) } } @@ -129,66 +105,51 @@ actual fun DeviceInitializationConfig( } } - Text("🌐 Netzwerk-Interface", style = MaterialTheme.typography.titleSmall) + var showInterfaces by remember { mutableStateOf(false) } + OutlinedButton( + onClick = { showInterfaces = !showInterfaces }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Text(if (showInterfaces) "⬆️ Netzwerk-Einstellungen verbergen" else "⬇️ Netzwerk-Einstellungen (Experten)") + } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (showInterfaces) { 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, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + border = if (isSelected) 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) + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(10.dp).background(if (info.isConnected) Color(0xFF4CAF50) else Color(0xFFF44336), CircleShape)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(info.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text("IP: ${info.address}", style = MaterialTheme.typography.bodySmall) } + if (isSelected) Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) } } } } - if (interfaces.isEmpty()) { - Text("⚠️ Kein aktives Netzwerk-Interface gefunden!", color = MaterialTheme.colorScheme.error) - } - + // SICHERHEITSSCHLÜSSEL var passwordVisible by remember { mutableStateOf(false) } MsTextField( value = settings.sharedKey, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, label = "Sicherheitsschlüssel (Sync-Key)", - helpDescription = "Das 'Turnier-Passwort'. Nur Geräte mit exakt diesem Schlüssel können Daten austauschen. Wichtig für die Verschlüsselung (DSGVO)!", + helpDescription = "Das 'Turnier-Passwort'. Muss auf allen Geräten gleich sein.", placeholder = "Mindestens 8 Zeichen", isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", visualTransformation = if (passwordVisible || uiState.isLocked) VisualTransformation.None else PasswordVisualTransformation(), imeAction = ImeAction.Next, - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) } - ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), modifier = Modifier.focusRequester(sharedKeyFocus), trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, onTrailingIconClick = { passwordVisible = !passwordVisible }, @@ -196,235 +157,103 @@ actual fun DeviceInitializationConfig( compact = true ) + // CLIENT-VERBINDUNG-FEEDBACK + if (settings.networkRole == NetworkRole.CLIENT && !uiState.isLocked) { + val masterSelected = uiState.selectedMaster != null + val canConnect = masterSelected && settings.sharedKey.isNotBlank() + + Surface( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + color = when (uiState.connectionStatus) { + ConnectionStatus.CONNECTED -> Color(0xFFE8F5E9) + ConnectionStatus.FAILED -> Color(0xFFFFEBEE) + else -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) + }, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, when (uiState.connectionStatus) { + ConnectionStatus.CONNECTED -> Color(0xFF4CAF50) + ConnectionStatus.FAILED -> Color(0xFFF44336) + else -> MaterialTheme.colorScheme.outlineVariant + }) + ) { + Column(Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + when (uiState.connectionStatus) { + ConnectionStatus.CONNECTING -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + ConnectionStatus.CONNECTED -> Icon(Icons.Default.CheckCircle, null, tint = Color(0xFF4CAF50)) + ConnectionStatus.FAILED -> Icon(Icons.Default.Error, null, tint = Color(0xFFF44336)) + else -> Icon(Icons.Default.Link, null) + } + Text( + text = when (uiState.connectionStatus) { + ConnectionStatus.SEARCHING -> "Warte auf Master-Auswahl..." + ConnectionStatus.CONNECTING -> "Verbindung wird aufgebaut..." + ConnectionStatus.CONNECTED -> "Verbunden mit ${uiState.selectedMaster?.name}" + ConnectionStatus.FAILED -> "Verbindung fehlgeschlagen!" + else -> "Bereit zum Verbinden" + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + if (uiState.connectionStatus != ConnectionStatus.CONNECTED) { + Button( + onClick = { viewModel.connectToMaster() }, + enabled = canConnect && uiState.connectionStatus != ConnectionStatus.CONNECTING, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (uiState.connectionStatus == ConnectionStatus.CONNECTING) "Verbinde..." else "Jetzt verbinden") + } + } + } + } + } + + // BACKUP & DRUCKER MsFilePicker( - label = "Backup-Verzeichnis (Pfad)", - helpDescription = "Wähle hier deinen USB-Stick oder einen lokalen Ordner aus. Die App speichert hier laufend Sicherheitskopien für den Notfall (Plan-USB).", + label = "Backup-Verzeichnis (Plan-USB)", selectedPath = settings.backupPath, - onFileSelected = { selectedPath -> - viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) } - }, + onFileSelected = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, directoryOnly = true, modifier = Modifier.focusRequester(backupPathFocus), enabled = !uiState.isLocked ) - 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) - } - } - - 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()) } - } - } - - MsStringDropdown( - label = "Standard-Drucker", - helpDescription = "Der Drucker, der standardmäßig für Protokolle und Listen verwendet wird. Kann später jederzeit geändert werden.", - options = printers, - selectedOption = settings.defaultPrinter, - onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } }, - placeholder = "Drucker auswählen...", - enabled = !uiState.isLocked, - modifier = Modifier.padding(bottom = 8.dp) - ) - if (settings.networkRole == NetworkRole.MASTER) { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Text("⏱️ Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.titleSmall) - Slider( - value = settings.syncInterval.toFloat(), - onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } }, - valueRange = 1f..60f, - steps = 59, + val printers = remember { PrintServiceLookup.lookupPrintServices(null, null).map { it.name } } + MsStringDropdown( + label = "Standard-Drucker", + options = printers, + selectedOption = settings.defaultPrinter, + onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } }, enabled = !uiState.isLocked ) } + // MASTER: ERWARTETE CLIENTS if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) { HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall) + TextButton(onClick = { /* Add Client Dialog */ }) { + Icon(Icons.Default.Add, null, Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Hinzufügen") + } + } settings.expectedClients.forEachIndexed { index, client -> ListItem( - headlineContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - client.name, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium - ) - SuggestionChip( - onClick = {}, - label = { Text(client.role.name) }, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - labelColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) - } - }, - supportingContent = { - Text( - if (client.isOnline) "Verbunden" else "Offline", - style = MaterialTheme.typography.labelSmall, - color = if (client.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - }, + headlineContent = { Text(client.name, fontWeight = FontWeight.Medium) }, + supportingContent = { Text(client.role.name, style = MaterialTheme.typography.labelSmall) }, trailingContent = { - IconButton(onClick = { - val clientName = settings.expectedClients[index].name - viewModel.removeExpectedClient(index) - println("[DeviceInit] Client entfernt: $clientName") - }) { - Icon( - Icons.Default.Delete, - contentDescription = "Löschen", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) + IconButton(onClick = { viewModel.removeExpectedClient(index) }) { + Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp)) } }, - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - modifier = Modifier.padding(vertical = 4.dp) - ) - } - - var newClientName by remember { mutableStateOf("") } - var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) } - var showAddClient by remember { mutableStateOf(false) } - - if (showAddClient) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - LaunchedEffect(Unit) { clientNameFocus.requestFocus() } - ClientEntryRow( - name = newClientName, - onNameChange = { newClientName = it }, - role = newClientRole, - onRoleChange = { newClientRole = it }, - focusManager = focusManager, - clientNameFocus = clientNameFocus, - clientRoleFocus = clientRoleFocus, - onEnter = { - if (newClientName.isNotBlank()) { - viewModel.addExpectedClient(newClientName, newClientRole) - println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)") - newClientName = "" - showAddClient = false - } - } - ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = { - showAddClient = false - newClientName = "" - }) { - Text("Abbrechen") - } - Spacer(Modifier.width(8.dp)) - Button( - onClick = { - if (newClientName.isNotBlank()) { - viewModel.addExpectedClient(newClientName, newClientRole) - println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)") - newClientName = "" - showAddClient = false - } - }, - enabled = newClientName.isNotBlank() - ) { - Text("Client speichern") - } - } - } - } else { - TextButton(onClick = { showAddClient = true }) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("Client hinzufügen") - } - } - } else if (settings.networkRole != NetworkRole.MASTER && !uiState.isLocked) { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Text("🔍 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleSmall) - - if (uiState.discoveredMasters.isEmpty()) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(12.dp)) - Text( - "Warte auf Master-Signal...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - uiState.discoveredMasters.forEach { service -> - ListItem( - headlineContent = { Text(service.name) }, - supportingContent = { Text("${service.host}:${service.port}") }, - trailingContent = { - Button(onClick = { - viewModel.updateSettings { s -> s.copy(sharedKey = service.metadata["key"] ?: s.sharedKey) } - }) { - Text("Verbinden") - } - }, - colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer) - ) - } - - Text( - "Hinweis: Als Client wird dieses Gerät automatisch versuchen, den Master im Netzwerk zu finden.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (settings.networkRole == NetworkRole.MASTER && uiState.isLocked && settings.expectedClients.isNotEmpty()) { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Text("👥 Erwartete Clients", style = MaterialTheme.typography.titleSmall) - settings.expectedClients.forEach { client -> - ListItem( - headlineContent = { Text(client.name) }, - trailingContent = { - SuggestionChip(onClick = {}, label = { Text(client.role.name) }) - } + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + modifier = Modifier.padding(vertical = 2.dp) ) } } @@ -432,52 +261,4 @@ 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, - onNameChange: (String) -> Unit, - role: NetworkRole, - onRoleChange: (NetworkRole) -> Unit, - focusManager: androidx.compose.ui.focus.FocusManager, - clientNameFocus: FocusRequester, - clientRoleFocus: FocusRequester, - onEnter: () -> Unit -) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - MsTextField( - value = name, - onValueChange = onNameChange, - label = "Gerätename des Clients", - modifier = Modifier.weight(1f).focusRequester(clientNameFocus), - imeAction = ImeAction.Next, - keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) - ) - - MsEnumDropdown( - label = "Rolle", - options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(), - selectedOption = role, - onOptionSelected = onRoleChange, - modifier = Modifier.weight(0.5f).focusRequester(clientRoleFocus).onKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { - onEnter() - true - } else { - false - } - } - ) - } -} +private data class InterfaceInfo(val id: String, val name: String, val address: String, val hardwareName: String, val isConnected: Boolean)