diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt index cc52b7a0..e061caf0 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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.material3.* 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.OnboardingValidator 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.swing.JFileChooser +@OptIn(ExperimentalMaterial3Api::class) @Composable fun OnboardingScreen( settings: OnboardingSettings, onSettingsChange: (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 { Surface(color = MaterialTheme.colorScheme.background) { Column( @@ -50,245 +63,354 @@ fun OnboardingScreen( fontWeight = FontWeight.SemiBold ) Text( - "Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).", + if (currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - var showPw by remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - 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) + 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( - "Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + "Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.", + style = MaterialTheme.typography.bodySmall ) - settings.expectedClients.forEachIndexed { index, client -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + 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) } + 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( - 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) + value = settings.geraetName, + onValueChange = {}, + readOnly = true, + label = "Gerätename (Vom Master freigegeben)", + trailingIcon = Icons.Default.ArrowDropDown, + modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) ) - - 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 -> + 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(role.name) }, + text = { Text(slot) }, onClick = { - val newList = settings.expectedClients.toMutableList() - newList[index] = client.copy(role = role) - onSettingsChange(settings.copy(expectedClients = newList)) + onSettingsChange(settings.copy(geraetName = slot)) 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") - } - } - - 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") - } - } + 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) }) ) } } - } - val canContinue = OnboardingValidator.canContinue(settings) - Button( - onClick = { onContinue(settings) }, - enabled = canContinue, - modifier = Modifier.align(Alignment.End) - ) { - Text("Konfiguration speichern & starten") - } + // 3.1 ERWARTETE GERÄTE (NUR MASTER) + if (settings.networkRole == NetworkRole.MASTER) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("📋 Erwartete Geräte (Clients)", style = MaterialTheme.typography.titleMedium) + Text( + "Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - 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 - ) + settings.expectedClients.forEachIndexed { index, client -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + 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)) + } + } } } }