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,27 +63,136 @@ 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
) )
if (currentStep == 0) {
// PHASE 1: NETZWERK-ROLLE
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleMedium)
Text(
"Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.",
style = MaterialTheme.typography.bodySmall
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) },
shape = MaterialTheme.shapes.medium,
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) } var showPw by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// 2.1 / 2.2 IDENTITÄT & SICHERHEIT
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium) Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
if (settings.networkRole == NetworkRole.MASTER) {
MsTextField( MsTextField(
value = settings.geraetName, value = settings.geraetName,
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) }, onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
label = "Gerätename (Pflicht)", label = "Gerätename (Pflicht)",
placeholder = "z. B. Meldestelle-PC-1", placeholder = "z. B. Haupt-PC",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.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(
value = settings.geraetName,
onValueChange = {},
readOnly = true,
label = "Gerätename (Vom Master freigegeben)",
trailingIcon = Icons.Default.ArrowDropDown,
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
if (availableSlots.isEmpty()) {
DropdownMenuItem(
text = { Text("Suche nach verfügbaren Slots...") },
onClick = { expanded = false }
)
} else {
availableSlots.forEach { slot ->
DropdownMenuItem(
text = { Text(slot) },
onClick = {
onSettingsChange(settings.copy(geraetName = slot))
expanded = false
}
)
}
}
}
}
}
MsTextField( MsTextField(
value = settings.sharedKey, value = settings.sharedKey,
@@ -87,65 +209,11 @@ fun OnboardingScreen(
} }
} }
// 3.1 ERWARTETE GERÄTE (NUR MASTER)
if (settings.networkRole == NetworkRole.MASTER) {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium) Text("📋 Erwartete Geräte (Clients)", 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.", "Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -195,7 +263,11 @@ fun OnboardingScreen(
newList.removeAt(index) newList.removeAt(index)
onSettingsChange(settings.copy(expectedClients = newList)) onSettingsChange(settings.copy(expectedClients = newList))
}) { }) {
Icon(Icons.Default.Delete, contentDescription = "Entfernen", tint = MaterialTheme.colorScheme.error) Icon(
Icons.Default.Delete,
contentDescription = "Entfernen",
tint = MaterialTheme.colorScheme.error
)
} }
} }
} }
@@ -213,6 +285,59 @@ fun OnboardingScreen(
Text("Gerät hinzufügen") 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) } var showPrinterDialog by remember { mutableStateOf(false) }
val availablePrinters = remember { val availablePrinters = remember {
@@ -222,7 +347,7 @@ fun OnboardingScreen(
MsTextField( MsTextField(
value = settings.defaultPrinter, value = settings.defaultPrinter,
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) }, onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
label = "🖨️ Standard-Drucker", label = "Standard-Drucker",
placeholder = "Name des Standard-Druckers", placeholder = "Name des Standard-Druckers",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.Print, trailingIcon = Icons.Default.Print,
@@ -253,10 +378,7 @@ fun OnboardingScreen(
.padding(vertical = 12.dp, horizontal = 8.dp), .padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton( RadioButton(selected = settings.defaultPrinter == printer, onClick = null)
selected = settings.defaultPrinter == printer,
onClick = null
)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(printer) Text(printer)
} }
@@ -264,31 +386,31 @@ fun OnboardingScreen(
} }
} }
}, },
confirmButton = { confirmButton = { TextButton(onClick = { showPrinterDialog = false }) { Text("Schließen") } }
TextButton(onClick = { showPrinterDialog = false }) {
Text("Schließen")
}
}
) )
} }
} }
} }
val canContinue = OnboardingValidator.canContinue(settings) 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( Button(
onClick = { onContinue(settings) }, onClick = { onContinue(settings) },
enabled = canContinue, enabled = OnboardingValidator.canContinue(settings)
modifier = Modifier.align(Alignment.End)
) { ) {
Text("Konfiguration speichern & starten") Text("Konfiguration abschließen")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
}
} }
if (!canContinue) {
Text(
"Bitte alle Pflichtfelder korrekt ausfüllen (Name min. 3, Key min. 8, Backup-Pfad gesetzt).",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
} }
} }
} }