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:
2026-04-30 15:58:19 +02:00
parent 8ab6ab1c2a
commit 022ffccccd
10 changed files with 307 additions and 400 deletions
@@ -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() },
@@ -8,9 +8,19 @@ import at.mocode.frontend.features.device.initialization.domain.model.DeviceInit
data class DeviceInitializationUiState(
val settings: DeviceInitializationSettings = DeviceInitializationSettings(),
val discoveredMasters: List<DiscoveredService> = 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
}
@@ -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)
}
}
@@ -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)