Per explizitem Nutzerwunsch die "Port freimachen?"-Abfrage samt allen Warnungen aus dem LAN-Schritt des Wizards auch auf den Experte-Tab angewendet, ausgeloest durch echten Live-Fall (ether4 wurde manuell ueber Expert als eigenes Netz angelegt, blieb dabei unbemerkt Bridge- Mitglied - zwei DHCP-Server im selben Broadcast-Domain). PortConflictWarningView aus LanStepView.swift in Features/Shared/ extrahiert (jetzt von Wizard UND Experte-Tab genutzt), neuer immediateApply-Parameter fuer die kontextabhaengige Abschluss-Meldung (Wizard: erst bei "Jetzt anwenden"; Experte: sofort bei "Anlegen"/ "Speichern"). Neue PortConflict.resolutionCommandsIncludingBridgeDetach() - der Experte-Tab hat anders als der Wizard keinen separaten, automatischen Bridge-Detach-Schritt, muss die Bridge-Entfernung also selbst mit auflisten. ExpertViewModel bekommt dieselbe Race-sichere Generation-Zaehler-Logik wie SetupViewModel (bugs.md #1), angewendet auf das .interfacePick-Feld des jeweils offenen Schemas. Speichern-Button gesperrt bis Konflikt bestaetigt oder Port gewechselt. 6 neue Regressionstests. Build + alle 111 Unit-Tests gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
106 lines
5.6 KiB
Swift
106 lines
5.6 KiB
Swift
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,
|
|
immediateApply: false,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// `PortConflictWarningView` now lives in Features/Shared/ — shared with the Experte tab's
|
|
// equivalent flow (see that file's doc comment for why).
|