import SwiftUI struct LanStepView: View { @ObservedObject var viewModel: SetupViewModel let availableInterfaces: [NetworkInterface] @AppStorage("appLanguage") private var appLanguage: String = "de" var body: some View { Form { ForEach($viewModel.lanConfigs) { $config in Section(L10n.t("Netzwerk auf", appLanguage) + " \(config.interfaceName)") { Picker(L10n.t("Anschluss/Bridge", appLanguage), selection: $config.interfaceName) { ForEach(availableInterfaces) { interface in Text(interface.name).tag(interface.name) } } .help(L10n.t("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).", appLanguage)) .onChange(of: config.interfaceName) { _, _ in viewModel.checkPortConflict(for: config.id) } .onAppear { viewModel.checkPortConflict(for: config.id) } if viewModel.isCheckingPortConflict.contains(config.id) { HStack { ProgressView().controlSize(.small) Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary) } } else if let conflict = viewModel.lanPortConflicts[config.id] { PortConflictWarningView( conflict: conflict, isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id), appLanguage: appLanguage, onConfirm: { viewModel.acknowledgePortConflict(for: config.id) } ) } TextField(L10n.t("Router-Adresse (z.B. 192.168.88.1/24)", appLanguage), text: $config.routerAddress) .help(L10n.t("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.", appLanguage)) TextField(L10n.t("Netzwerk (z.B. 192.168.88.0/24)", appLanguage), text: $config.networkAddress) .help(L10n.t("Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).", appLanguage)) TextField(L10n.t("Automatische Adressvergabe von", appLanguage), text: $config.poolRangeStart) .help(L10n.t("Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.", appLanguage)) TextField(L10n.t("bis", appLanguage), text: $config.poolRangeEnd) .help(L10n.t("Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.", appLanguage)) Stepper( L10n.t("Adresse behalten für", appLanguage) + " \(config.leaseTimeHours) " + L10n.t("Stunden", appLanguage), value: $config.leaseTimeHours, in: 1...168 ) .help(L10n.t("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.", appLanguage)) TextField(L10n.t("DNS-Server", appLanguage), text: $config.dnsServers) .help(L10n.t("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.", appLanguage)) if viewModel.mode == .expert { Toggle(L10n.t("Von anderen Netzwerken isolieren", appLanguage), isOn: $config.isolated) .help(L10n.t("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.", appLanguage)) if viewModel.lanConfigs.count > 1 { Button(L10n.t("Netzwerk entfernen", appLanguage), role: .destructive) { viewModel.removeLan(config.id) } } } } } if viewModel.mode == .expert { Section { Button(L10n.t("Weiteres LAN-Netzwerk hinzufügen", appLanguage)) { viewModel.addLan() } } } Section { HStack { Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() } Spacer() Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() } .disabled(!isStepValid) } } } .formStyle(.grouped) .navigationTitle(LocalizedStringKey(L10n.t("Heimnetzwerk einrichten", appLanguage))) .toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepLAN) } } } private var isStepValid: Bool { viewModel.lanConfigs.allSatisfy { config in !config.interfaceName.isEmpty && !config.routerAddress.isEmpty && !config.networkAddress.isEmpty && !config.poolRangeStart.isEmpty && !config.poolRangeEnd.isEmpty && !viewModel.hasUnresolvedPortConflict(for: config.id) } } } /// Shown inline under a LAN config's port Picker once `SetupViewModel.checkPortConflict(for:)` /// finds the chosen port already carries other configuration. Requires two separate confirmations /// before the app is allowed to clear it — per explicit request: this silently overriding a port's /// existing role (an active WAN dial-up, a bridge membership, a manually-set address) previously /// wasn't visible to the user at all beyond `DhcpServerCommandBuilder`'s own unconditional bridge /// detach; this makes the consequences explicit and opt-in instead. private struct PortConflictWarningView: View { let conflict: PortConflict let isAcknowledged: Bool let appLanguage: String let onConfirm: () -> Void @State private var showConsequencesConfirmation = false @State private var showFinalConfirmation = false var body: some View { if isAcknowledged { Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield") .appFont(.caption) .foregroundStyle(.orange) } else { VStack(alignment: .leading, spacing: 6) { Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill") .appFont(.subheadline, bold: true) .foregroundStyle(.red) ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in Text("• \(reason.description)") .appFont(.caption) } Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) { showConsequencesConfirmation = true } } .padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08))) .confirmationDialog( L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage), isPresented: $showConsequencesConfirmation, titleVisibility: .visible ) { Button(L10n.t("Fortfahren", appLanguage), role: .destructive) { showFinalConfirmation = true } Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {} } message: { Text(consequenceText) } .confirmationDialog( L10n.t("Wirklich sicher?", appLanguage), isPresented: $showFinalConfirmation, titleVisibility: .visible ) { Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) { onConfirm() } Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {} } message: { Text(L10n.t("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.", appLanguage)) } } } private var consequenceText: String { ([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)] + conflict.reasons.map { "• \($0.description)" } + [L10n.t("Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen.", appLanguage)]) .joined(separator: "\n") } }