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>
86 lines
4.7 KiB
Swift
86 lines
4.7 KiB
Swift
import SwiftUI
|
|
|
|
/// Shown inline wherever a user is about to configure a physical port that
|
|
/// `ConnectionService.checkPortConflict(interfaceName:)` found already carrying other
|
|
/// configuration — originally the Setup Wizard's LAN step only (found.md #1's "Port-Konflikt-
|
|
/// Prüfung"), now also the Experte tab's generic `.interfacePick` fields (per explicit request:
|
|
/// "die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen").
|
|
/// 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.
|
|
struct PortConflictWarningView: View {
|
|
let conflict: PortConflict
|
|
let isAcknowledged: Bool
|
|
let appLanguage: String
|
|
/// Wizard: resolution runs later, batched with everything else at "Jetzt anwenden" — the
|
|
/// final confirmation dialog says so, and that a port switch above still undoes it. Experte
|
|
/// tab: there's no separate review/apply step, "Anlegen"/"Speichern" runs it immediately —
|
|
/// a different final-confirmation message reflects that instead.
|
|
let immediateApply: Bool
|
|
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(immediateApply
|
|
? L10n.t("Wird sofort ausgeführt, sobald du unten auf \"Anlegen\"/\"Speichern\" klickst.", appLanguage)
|
|
: 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")
|
|
}
|
|
}
|