feat(device-initialization, core): mDNS-Discovery erweitert, Geräte- und UI-Interaktion optimiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
@@ -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)
|
Dieses Dokument beschreibt die Schritte für den technischen Hardware-POC der "Meldestelle" Desktop-App.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
|
## 1. Bauen der App
|
||||||
|
Führen Sie auf Ihrem Entwicklungsrechner aus:
|
||||||
```bash
|
```bash
|
||||||
./gradlew :frontend:shells:meldestelle-desktop:createDistributable
|
./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:
|
## 2. Test am Master-PC (PC-1)
|
||||||
`frontend/shells/meldestelle-desktop/build/compose/binaries/main/app`
|
1. Starten Sie die App vom Stick.
|
||||||
* **Vorteil:** Keine Installation auf dem Ziel-System notwendig, läuft direkt aus dem Ordner (Portable).
|
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
|
## 3. Test am Client-PC (PC-2)
|
||||||
Für den POC müssen die Basis-Dienste (Zora-Stack) laufen.
|
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.
|
## 4. Erfolgskriterien
|
||||||
* **Start:** `docker-compose up -d` (Stellt sicher, dass das Backend erreichbar ist, falls die App Daten synchronisieren will).
|
- [ ] Master wird vom Client automatisch gefunden (mDNS).
|
||||||
* **Verifikation:** Alle Container müssen `healthy` sein.
|
- [ ] Client kann sich per Klick verbinden.
|
||||||
|
- [ ] Native Dateidialoge sind lesbar und stabil.
|
||||||
### 3. Pipeline & Branch-Optimierung
|
- [ ] Handshake-Feedback (Grün/Rot) funktioniert.
|
||||||
* **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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+15
-9
@@ -11,7 +11,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import java.awt.FileDialog
|
import java.awt.FileDialog
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.swing.JFileChooser
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MsFilePicker(
|
actual fun MsFilePicker(
|
||||||
@@ -45,19 +44,26 @@ actual fun MsFilePicker(
|
|||||||
MsButton(
|
MsButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (directoryOnly) {
|
if (directoryOnly) {
|
||||||
// JFileChooser ist für Verzeichnisse auf dem Desktop oft stabiler/einfacher
|
// AWT FileDialog für nativen Look auch bei Verzeichnissen (Windows/Linux/macOS)
|
||||||
val chooser = JFileChooser().apply {
|
// unter macOS erzwingt dies die Verzeichnisauswahl. Unter Windows/Linux ist es der Standard-Dialog.
|
||||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||||
dialogTitle = label
|
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
|
||||||
selectedPath?.let {
|
selectedPath?.let {
|
||||||
val currentDir = File(it)
|
val currentDir = File(it)
|
||||||
if (currentDir.exists()) currentDirectory = currentDir
|
if (currentDir.exists()) {
|
||||||
|
directory = currentDir.absolutePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val result = chooser.showOpenDialog(null)
|
dialog.isVisible = true
|
||||||
if (result == JFileChooser.APPROVE_OPTION) {
|
if (dialog.directory != null && dialog.file != null) {
|
||||||
onFileSelected(chooser.selectedFile.absolutePath)
|
// 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 {
|
} else {
|
||||||
// AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht)
|
// AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht)
|
||||||
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
|
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
|
||||||
|
|||||||
+2
-1
@@ -38,8 +38,9 @@ interface NetworkDiscoveryService {
|
|||||||
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
|
||||||
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
|
* @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 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).
|
* Gibt die Liste der aktuell entdeckten Dienste zurück (Snapshot).
|
||||||
|
|||||||
+13
-4
@@ -63,20 +63,29 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
|
|||||||
_discoveredServices.value = emptyList()
|
_discoveredServices.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun registerService(port: Int, preferredIp: String?) {
|
override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {
|
||||||
if (jmdns == null) {
|
if (jmdns == null) {
|
||||||
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
|
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
|
||||||
println("[Discovery] Registriere Dienst gebunden an: $addr")
|
println("[Discovery] Registriere Dienst gebunden an: $addr")
|
||||||
jmdns = JmDNS.create(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(
|
val serviceInfo = ServiceInfo.create(
|
||||||
SERVICE_TYPE,
|
SERVICE_TYPE,
|
||||||
"Meldestelle-${System.getProperty("user.name")}",
|
name,
|
||||||
port,
|
port,
|
||||||
"Offline-First Sync Node"
|
0, 0, // weight, priority
|
||||||
|
mapOf(
|
||||||
|
"version" to "1.0.0",
|
||||||
|
"type" to "master",
|
||||||
|
"nodeId" to name
|
||||||
|
)
|
||||||
)
|
)
|
||||||
jmdns?.registerService(serviceInfo)
|
jmdns?.registerService(serviceInfo)
|
||||||
println("[Discovery] Eigenen Dienst registriert auf Port $port")
|
println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDiscoveredServices(): List<DiscoveredService> {
|
override fun getDiscoveredServices(): List<DiscoveredService> {
|
||||||
|
|||||||
+1
-1
@@ -19,6 +19,6 @@ class NoOpDiscoveryService : NetworkDiscoveryService {
|
|||||||
|
|
||||||
override fun startDiscovery(preferredIp: String?) {}
|
override fun startDiscovery(preferredIp: String?) {}
|
||||||
override fun stopDiscovery() {}
|
override fun stopDiscovery() {}
|
||||||
override fun registerService(port: Int, preferredIp: String?) {}
|
override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {}
|
||||||
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
|
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-1
@@ -3,12 +3,14 @@
|
|||||||
package at.mocode.frontend.features.device.initialization.presentation
|
package at.mocode.frontend.features.device.initialization.presentation
|
||||||
|
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Edit
|
||||||
import androidx.compose.material.icons.filled.NetworkCheck
|
import androidx.compose.material.icons.filled.NetworkCheck
|
||||||
import androidx.compose.material3.*
|
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.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
|
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -191,7 +194,7 @@ fun DeviceInitializationScreen(
|
|||||||
color = if (hasDiscoveries) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.15f)
|
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,
|
shape = MaterialTheme.shapes.medium,
|
||||||
border = if (hasDiscoveries) androidx.compose.foundation.BorderStroke(
|
border = if (hasDiscoveries) BorderStroke(
|
||||||
1.dp,
|
1.dp,
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||||
)
|
)
|
||||||
@@ -233,6 +236,70 @@ fun DeviceInitializationScreen(
|
|||||||
enabled = !uiState.isLocked
|
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) {
|
if (uiState.showRoleChangeWarning) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { viewModel.dismissRoleChangeWarning() },
|
onDismissRequest = { viewModel.dismissRoleChangeWarning() },
|
||||||
|
|||||||
+10
@@ -8,9 +8,19 @@ import at.mocode.frontend.features.device.initialization.domain.model.DeviceInit
|
|||||||
data class DeviceInitializationUiState(
|
data class DeviceInitializationUiState(
|
||||||
val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
|
val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
|
||||||
val discoveredMasters: List<DiscoveredService> = emptyList(),
|
val discoveredMasters: List<DiscoveredService> = emptyList(),
|
||||||
|
val selectedMaster: DiscoveredService? = null,
|
||||||
|
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
||||||
val isProcessing: Boolean = false,
|
val isProcessing: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isLocked: Boolean = false,
|
val isLocked: Boolean = false,
|
||||||
val showRoleChangeWarning: Boolean = false,
|
val showRoleChangeWarning: Boolean = false,
|
||||||
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null
|
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class ConnectionStatus {
|
||||||
|
DISCONNECTED,
|
||||||
|
SEARCHING,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|||||||
+42
-2
@@ -6,6 +6,7 @@ package at.mocode.frontend.features.device.initialization.presentation
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.mocode.frontend.core.network.backup.BackupService
|
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.core.network.discovery.NetworkDiscoveryService
|
||||||
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
|
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
|
||||||
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
|
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.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class DeviceInitializationViewModel(
|
class DeviceInitializationViewModel(
|
||||||
private val discoveryService: NetworkDiscoveryService,
|
private val discoveryService: NetworkDiscoveryService,
|
||||||
@@ -41,7 +43,44 @@ class DeviceInitializationViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
discoveryService.discoveredServices.collect { services ->
|
discoveryService.discoveredServices.collect { services ->
|
||||||
println("[DeviceInit] Discovery Update: ${services.size} Dienste gefunden.")
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
println("[DeviceInit] Starte/Restart Discovery für IP: $ip (Interface: $selectedInterface)")
|
println("[DeviceInit] Starte/Restart Discovery für IP: $ip (Interface: $selectedInterface)")
|
||||||
discoveryService.stopDiscovery()
|
discoveryService.stopDiscovery()
|
||||||
discoveryService.startDiscovery(ip)
|
discoveryService.startDiscovery(ip)
|
||||||
|
|
||||||
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
|
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
|
||||||
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
|
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
|
||||||
discoveryService.registerService(8080, ip)
|
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+108
-327
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
package at.mocode.frontend.features.device.initialization.presentation
|
package at.mocode.frontend.features.device.initialization.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.*
|
||||||
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
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.*
|
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.Companion.FocusRequesterFactory.component5
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.*
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
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.MsFilePicker
|
||||||
import at.mocode.frontend.core.designsystem.components.MsStringDropdown
|
import at.mocode.frontend.core.designsystem.components.MsStringDropdown
|
||||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||||
@@ -70,7 +66,7 @@ actual fun DeviceInitializationConfig(
|
|||||||
value = settings.deviceName,
|
value = settings.deviceName,
|
||||||
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
|
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
|
||||||
label = "Gerätename",
|
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",
|
placeholder = "z.B. Meldestelle-PC-1",
|
||||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||||
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||||
@@ -81,44 +77,24 @@ actual fun DeviceInitializationConfig(
|
|||||||
compact = true
|
compact = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NETZWERK-INTERFACES (EXPERTEN-MODUS)
|
||||||
val interfaces = remember {
|
val interfaces = remember {
|
||||||
NetworkInterface.getNetworkInterfaces().toList()
|
NetworkInterface.getNetworkInterfaces().toList()
|
||||||
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() }
|
.filter { it.isUp && !it.isLoopback && it.inetAddresses.hasMoreElements() }
|
||||||
.map { ni ->
|
.map { ni ->
|
||||||
val friendlyName = when {
|
val friendlyName = when {
|
||||||
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains(
|
ni.displayName.contains("wlan", ignoreCase = true) || ni.displayName.contains("wi-fi", ignoreCase = true) || ni.name.contains("wlan", ignoreCase = true) -> "🌐 WLAN"
|
||||||
"wi-fi",
|
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"
|
||||||
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
|
else -> "💻 " + ni.displayName
|
||||||
}
|
}
|
||||||
val address =
|
val address = ni.inetAddresses.asSequence().firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 }?.hostAddress
|
||||||
ni.inetAddresses.asSequence()
|
|
||||||
.firstOrNull { !it.isLinkLocalAddress && it.hostAddress.indexOf(':') == -1 }?.hostAddress
|
|
||||||
?: ni.inetAddresses.nextElement().hostAddress
|
?: ni.inetAddresses.nextElement().hostAddress
|
||||||
|
|
||||||
val isConnected = !ni.isLoopback && ni.isUp && ni.interfaceAddresses.any {
|
val isConnected = !ni.isLoopback && ni.isUp && ni.interfaceAddresses.any {
|
||||||
it.address.isSiteLocalAddress || it.address.hostAddress.startsWith("192.168") || it.address.hostAddress.startsWith(
|
it.address.isSiteLocalAddress || it.address.hostAddress.startsWith("192.168") || it.address.hostAddress.startsWith("10.")
|
||||||
"10."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
InterfaceInfo(
|
InterfaceInfo(id = "$friendlyName ($address)", name = friendlyName, address = address, hardwareName = ni.name, isConnected = isConnected)
|
||||||
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 ->
|
interfaces.forEach { info ->
|
||||||
val isSelected = settings.networkInterface == info.id
|
val isSelected = settings.networkInterface == info.id
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { if (!uiState.isLocked) viewModel.updateSettings { s -> s.copy(networkInterface = info.id) } },
|
onClick = { if (!uiState.isLocked) viewModel.updateSettings { s -> s.copy(networkInterface = info.id) } },
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(
|
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||||
alpha = 0.3f
|
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
),
|
|
||||||
border = if (isSelected) androidx.compose.foundation.BorderStroke(
|
|
||||||
2.dp,
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
) else null,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
modifier = Modifier.padding(16.dp),
|
Box(Modifier.size(10.dp).background(if (info.isConnected) Color(0xFF4CAF50) else Color(0xFFF44336), CircleShape))
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Spacer(Modifier.width(12.dp))
|
||||||
) {
|
Column(Modifier.weight(1f)) {
|
||||||
Box(
|
Text(info.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
modifier = Modifier
|
Text("IP: ${info.address}", style = MaterialTheme.typography.bodySmall)
|
||||||
.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 (isSelected) Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interfaces.isEmpty()) {
|
// SICHERHEITSSCHLÜSSEL
|
||||||
Text("⚠️ Kein aktives Netzwerk-Interface gefunden!", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
var passwordVisible by remember { mutableStateOf(false) }
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = settings.sharedKey,
|
value = settings.sharedKey,
|
||||||
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
|
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
|
||||||
label = "Sicherheitsschlüssel (Sync-Key)",
|
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",
|
placeholder = "Mindestens 8 Zeichen",
|
||||||
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
||||||
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
||||||
visualTransformation = if (passwordVisible || uiState.isLocked) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (passwordVisible || uiState.isLocked) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
imeAction = ImeAction.Next,
|
imeAction = ImeAction.Next,
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
|
||||||
),
|
|
||||||
modifier = Modifier.focusRequester(sharedKeyFocus),
|
modifier = Modifier.focusRequester(sharedKeyFocus),
|
||||||
trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||||
onTrailingIconClick = { passwordVisible = !passwordVisible },
|
onTrailingIconClick = { passwordVisible = !passwordVisible },
|
||||||
@@ -196,235 +157,103 @@ actual fun DeviceInitializationConfig(
|
|||||||
compact = true
|
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(
|
MsFilePicker(
|
||||||
label = "Backup-Verzeichnis (Pfad)",
|
label = "Backup-Verzeichnis (Plan-USB)",
|
||||||
helpDescription = "Wähle hier deinen USB-Stick oder einen lokalen Ordner aus. Die App speichert hier laufend Sicherheitskopien für den Notfall (Plan-USB).",
|
|
||||||
selectedPath = settings.backupPath,
|
selectedPath = settings.backupPath,
|
||||||
onFileSelected = { selectedPath ->
|
onFileSelected = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
|
||||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
|
||||||
},
|
|
||||||
directoryOnly = true,
|
directoryOnly = true,
|
||||||
modifier = Modifier.focusRequester(backupPathFocus),
|
modifier = Modifier.focusRequester(backupPathFocus),
|
||||||
enabled = !uiState.isLocked
|
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) {
|
if (settings.networkRole == NetworkRole.MASTER) {
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
val printers = remember { PrintServiceLookup.lookupPrintServices(null, null).map { it.name } }
|
||||||
Text("⏱️ Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.titleSmall)
|
MsStringDropdown(
|
||||||
Slider(
|
label = "Standard-Drucker",
|
||||||
value = settings.syncInterval.toFloat(),
|
options = printers,
|
||||||
onValueChange = { viewModel.updateSettings { s -> s.copy(syncInterval = it.toInt()) } },
|
selectedOption = settings.defaultPrinter,
|
||||||
valueRange = 1f..60f,
|
onOptionSelected = { viewModel.updateSettings { s -> s.copy(defaultPrinter = it) } },
|
||||||
steps = 59,
|
|
||||||
enabled = !uiState.isLocked
|
enabled = !uiState.isLocked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MASTER: ERWARTETE CLIENTS
|
||||||
if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
|
if (settings.networkRole == NetworkRole.MASTER && !uiState.isLocked) {
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
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 ->
|
settings.expectedClients.forEachIndexed { index, client ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = { Text(client.name, fontWeight = FontWeight.Medium) },
|
||||||
Row(
|
supportingContent = { Text(client.role.name, style = MaterialTheme.typography.labelSmall) },
|
||||||
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
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = { viewModel.removeExpectedClient(index) }) {
|
||||||
val clientName = settings.expectedClients[index].name
|
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
|
||||||
viewModel.removeExpectedClient(index)
|
|
||||||
println("[DeviceInit] Client entfernt: $clientName")
|
|
||||||
}) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = "Löschen",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = ListItemDefaults.colors(
|
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
modifier = Modifier.padding(vertical = 2.dp)
|
||||||
),
|
|
||||||
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) })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,52 +261,4 @@ actual fun DeviceInitializationConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InterfaceInfo(
|
private data class InterfaceInfo(val id: String, val name: String, val address: String, val hardwareName: String, val isConnected: Boolean)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user