feat(onboarding): Netzwerkrollen und automatisches Discovery im Onboarding hinzugefügt

- Unterstützung für Master- und Client-Rollen mit angepasster Konfiguration.
- Automatische Dienstsuche (Discovery) für Clients implementiert.
- Erweiterte UI für Drucker-, Backup- und Rollenspezifische Einstellungen.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-16 16:57:18 +02:00
parent b2e6c2427b
commit b8bd2744ac
@@ -10,6 +10,7 @@ import androidx.compose.foundation.text.KeyboardActions
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.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -29,15 +30,27 @@ import at.mocode.desktop.screens.onboarding.NetworkRole
import at.mocode.desktop.screens.onboarding.OnboardingSettings import at.mocode.desktop.screens.onboarding.OnboardingSettings
import at.mocode.desktop.screens.onboarding.OnboardingValidator import at.mocode.desktop.screens.onboarding.OnboardingValidator
import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import org.koin.compose.koinInject
import javax.print.PrintServiceLookup import javax.print.PrintServiceLookup
import javax.swing.JFileChooser import javax.swing.JFileChooser
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
settings: OnboardingSettings, settings: OnboardingSettings,
onSettingsChange: (OnboardingSettings) -> Unit, onSettingsChange: (OnboardingSettings) -> Unit,
onContinue: (OnboardingSettings) -> Unit, onContinue: (OnboardingSettings) -> Unit,
) { ) {
var currentStep by remember { mutableStateOf(0) }
val discoveryService: NetworkDiscoveryService = koinInject()
val discoveredServices by remember { mutableStateOf(discoveryService.getDiscoveredServices()) }
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
LaunchedEffect(currentStep) {
if (currentStep == 0) discoveryService.startDiscovery()
}
DesktopThemeV2 { DesktopThemeV2 {
Surface(color = MaterialTheme.colorScheme.background) { Surface(color = MaterialTheme.colorScheme.background) {
Column( Column(
@@ -50,245 +63,354 @@ fun OnboardingScreen(
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
Text( Text(
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).", if (currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
var showPw by remember { mutableStateOf(false) } if (currentStep == 0) {
val focusManager = LocalFocusManager.current // PHASE 1: NETZWERK-ROLLE
Card(modifier = Modifier.fillMaxWidth()) {
Card(modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleMedium)
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
MsTextField(
value = settings.geraetName,
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
label = "Gerätename (Pflicht)",
placeholder = "z. B. Meldestelle-PC-1",
modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
MsTextField(
value = settings.sharedKey,
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
label = "Sicherheitsschlüssel (Pflicht)",
placeholder = "Shared Secret für Netzwerk-Sync",
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { showPw = !showPw },
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium)
MsTextField(
value = settings.backupPath,
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
label = "💾 Datenbank-Sicherungspfad (Backup)",
placeholder = "Pfad zum Backup-Verzeichnis",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.FolderOpen,
onTrailingIconClick = {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Backup-Verzeichnis auswählen"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
}
},
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
Text("🌐 Netzwerk-Rolle", style = MaterialTheme.typography.labelLarge)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.MASTER,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
)
Text(
"Master (Hostet lokale DB)",
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) })
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.CLIENT,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
)
Text(
"Client",
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) })
}
}
Column {
Text("📡 Sync-Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
Slider(
value = settings.syncInterval.toFloat(),
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
valueRange = 1f..60f,
steps = 59,
modifier = Modifier.fillMaxWidth()
)
}
if (settings.networkRole == NetworkRole.MASTER) {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("📋 Erwartete Geräte (Clients)", style = MaterialTheme.typography.titleSmall)
Text( Text(
"Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.", "Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
settings.expectedClients.forEachIndexed { index, client -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( Surface(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) },
verticalAlignment = Alignment.CenterVertically, shape = MaterialTheme.shapes.medium,
horizontalArrangement = Arrangement.spacedBy(8.dp) color = if (settings.networkRole == NetworkRole.MASTER) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.MASTER,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
)
Column {
Text("Master (Host)", style = MaterialTheme.typography.labelLarge)
Text(
"Verwaltet die zentrale Datenbank und koordiniert den Sync.",
style = MaterialTheme.typography.bodySmall
)
}
}
}
Surface(
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) },
shape = MaterialTheme.shapes.medium,
color = if (settings.networkRole == NetworkRole.CLIENT) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.CLIENT,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
)
Column {
Text("Client", style = MaterialTheme.typography.labelLarge)
Text(
"Verbindet sich mit einem Master und synchronisiert Daten.",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
Button(
onClick = { currentStep = 1 },
modifier = Modifier.align(Alignment.End)
) {
Text("Weiter")
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, Modifier.padding(start = 8.dp))
}
}
}
} else {
// PHASE 2: ROLLENSPEZIFISCH
var showPw by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
// 2.1 / 2.2 IDENTITÄT & SICHERHEIT
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
if (settings.networkRole == NetworkRole.MASTER) {
MsTextField(
value = settings.geraetName,
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
label = "Gerätename (Pflicht)",
placeholder = "z. B. Haupt-PC",
modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
} else {
// Client: Auswahlbox
var expanded by remember { mutableStateOf(false) }
val availableSlots =
discoveredServices.flatMap { it.metadata["availableSlots"]?.split(",") ?: emptyList() }
.filter { it.isNotBlank() }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) { ) {
MsTextField( MsTextField(
value = client.name, value = settings.geraetName,
onValueChange = { newName -> onValueChange = {},
val newList = settings.expectedClients.toMutableList() readOnly = true,
newList[index] = client.copy(name = newName) label = "Gerätename (Vom Master freigegeben)",
onSettingsChange(settings.copy(expectedClients = newList)) trailingIcon = Icons.Default.ArrowDropDown,
}, modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
label = "Name",
modifier = Modifier.weight(1f)
) )
ExposedDropdownMenu(
var expanded by remember { mutableStateOf(false) } expanded = expanded,
Box { onDismissRequest = { expanded = false }
OutlinedButton(onClick = { expanded = true }) { ) {
Text(client.role.name) if (availableSlots.isEmpty()) {
Icon(Icons.Default.ArrowDropDown, null) DropdownMenuItem(
} text = { Text("Suche nach verfügbaren Slots...") },
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { onClick = { expanded = false }
NetworkRole.entries.filter { it != NetworkRole.MASTER }.forEach { role -> )
} else {
availableSlots.forEach { slot ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(role.name) }, text = { Text(slot) },
onClick = { onClick = {
val newList = settings.expectedClients.toMutableList() onSettingsChange(settings.copy(geraetName = slot))
newList[index] = client.copy(role = role)
onSettingsChange(settings.copy(expectedClients = newList))
expanded = false expanded = false
} }
) )
} }
} }
} }
IconButton(onClick = {
val newList = settings.expectedClients.toMutableList()
newList.removeAt(index)
onSettingsChange(settings.copy(expectedClients = newList))
}) {
Icon(Icons.Default.Delete, contentDescription = "Entfernen", tint = MaterialTheme.colorScheme.error)
}
} }
} }
TextButton( MsTextField(
onClick = { value = settings.sharedKey,
val newList = settings.expectedClients.toMutableList() onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
newList.add(ExpectedClient("Neues Gerät", NetworkRole.CLIENT)) label = "Sicherheitsschlüssel (Pflicht)",
onSettingsChange(settings.copy(expectedClients = newList)) placeholder = "Shared Secret für Netzwerk-Sync",
}, trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
modifier = Modifier.padding(top = 8.dp) onTrailingIconClick = { showPw = !showPw },
) { visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
Icon(Icons.Default.Add, null) modifier = Modifier.fillMaxWidth(),
Spacer(Modifier.width(8.dp)) imeAction = ImeAction.Next,
Text("Gerät hinzufügen") keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
}
}
var showPrinterDialog by remember { mutableStateOf(false) }
val availablePrinters = remember {
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
}
MsTextField(
value = settings.defaultPrinter,
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
label = "🖨️ Standard-Drucker",
placeholder = "Name des Standard-Druckers",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.Print,
onTrailingIconClick = { showPrinterDialog = true },
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (OnboardingValidator.canContinue(settings)) onContinue(settings)
})
)
if (showPrinterDialog) {
AlertDialog(
onDismissRequest = { showPrinterDialog = false },
title = { Text("Drucker auswählen") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
if (availablePrinters.isEmpty()) {
Text("Keine Drucker gefunden", style = MaterialTheme.typography.bodyMedium)
} else {
availablePrinters.forEach { printer ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSettingsChange(settings.copy(defaultPrinter = printer))
showPrinterDialog = false
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = settings.defaultPrinter == printer,
onClick = null
)
Spacer(Modifier.width(8.dp))
Text(printer)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showPrinterDialog = false }) {
Text("Schließen")
}
}
) )
} }
} }
}
val canContinue = OnboardingValidator.canContinue(settings) // 3.1 ERWARTETE GERÄTE (NUR MASTER)
Button( if (settings.networkRole == NetworkRole.MASTER) {
onClick = { onContinue(settings) }, Card(modifier = Modifier.fillMaxWidth()) {
enabled = canContinue, Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
modifier = Modifier.align(Alignment.End) Text("📋 Erwartete Geräte (Clients)", style = MaterialTheme.typography.titleMedium)
) { Text(
Text("Konfiguration speichern & starten") "Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.",
} style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (!canContinue) { settings.expectedClients.forEachIndexed { index, client ->
Text( Row(
"Bitte alle Pflichtfelder korrekt ausfüllen (Name min. 3, Key min. 8, Backup-Pfad gesetzt).", modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.error, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.labelSmall horizontalArrangement = Arrangement.spacedBy(8.dp)
) ) {
MsTextField(
value = client.name,
onValueChange = { newName ->
val newList = settings.expectedClients.toMutableList()
newList[index] = client.copy(name = newName)
onSettingsChange(settings.copy(expectedClients = newList))
},
label = "Name",
modifier = Modifier.weight(1f)
)
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) {
Text(client.role.name)
Icon(Icons.Default.ArrowDropDown, null)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
NetworkRole.entries.filter { it != NetworkRole.MASTER }.forEach { role ->
DropdownMenuItem(
text = { Text(role.name) },
onClick = {
val newList = settings.expectedClients.toMutableList()
newList[index] = client.copy(role = role)
onSettingsChange(settings.copy(expectedClients = newList))
expanded = false
}
)
}
}
}
IconButton(onClick = {
val newList = settings.expectedClients.toMutableList()
newList.removeAt(index)
onSettingsChange(settings.copy(expectedClients = newList))
}) {
Icon(
Icons.Default.Delete,
contentDescription = "Entfernen",
tint = MaterialTheme.colorScheme.error
)
}
}
}
TextButton(
onClick = {
val newList = settings.expectedClients.toMutableList()
newList.add(ExpectedClient("Neues Gerät", NetworkRole.CLIENT))
onSettingsChange(settings.copy(expectedClients = newList))
},
modifier = Modifier.padding(top = 8.dp)
) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(8.dp))
Text("Gerät hinzufügen")
}
}
}
}
// 4.1 / 3.2 DATENBANK-SICHERHEITSPFAD
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
if (settings.networkRole == NetworkRole.MASTER) "💾 Datenbank-Sicherheitspfad" else "💾 Lokaler Cache-Sicherungspfad",
style = MaterialTheme.typography.titleMedium
)
MsTextField(
value = settings.backupPath,
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
label = "Pfad auswählen",
placeholder = "Verzeichnis für Backups/Cache",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.FolderOpen,
onTrailingIconClick = {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Verzeichnis auswählen"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
}
},
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
}
}
// 5.1 SYNC-INTERVALL (NUR MASTER)
if (settings.networkRole == NetworkRole.MASTER) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🔄 Sync-Intervall", style = MaterialTheme.typography.titleMedium)
Text("📡 Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
Slider(
value = settings.syncInterval.toFloat(),
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
valueRange = 1f..60f,
steps = 59,
modifier = Modifier.fillMaxWidth()
)
}
}
}
// 6.1 / 4.2 DRUCKER
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🖨️ Drucker", style = MaterialTheme.typography.titleMedium)
var showPrinterDialog by remember { mutableStateOf(false) }
val availablePrinters = remember {
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
}
MsTextField(
value = settings.defaultPrinter,
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
label = "Standard-Drucker",
placeholder = "Name des Standard-Druckers",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.Print,
onTrailingIconClick = { showPrinterDialog = true },
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (OnboardingValidator.canContinue(settings)) onContinue(settings)
})
)
if (showPrinterDialog) {
AlertDialog(
onDismissRequest = { showPrinterDialog = false },
title = { Text("Drucker auswählen") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
if (availablePrinters.isEmpty()) {
Text("Keine Drucker gefunden", style = MaterialTheme.typography.bodyMedium)
} else {
availablePrinters.forEach { printer ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSettingsChange(settings.copy(defaultPrinter = printer))
showPrinterDialog = false
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = settings.defaultPrinter == printer, onClick = null)
Spacer(Modifier.width(8.dp))
Text(printer)
}
}
}
}
},
confirmButton = { TextButton(onClick = { showPrinterDialog = false }) { Text("Schließen") } }
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { currentStep = 0 }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
Spacer(Modifier.width(8.dp))
Text("Zurück zur Rollenauswahl")
}
Button(
onClick = { onContinue(settings) },
enabled = OnboardingValidator.canContinue(settings)
) {
Text("Konfiguration abschließen")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
}
}
} }
} }
} }