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>
439 lines
19 KiB
Swift
439 lines
19 KiB
Swift
import SwiftUI
|
||
|
||
struct ExpertMenuDetailView: View {
|
||
@ObservedObject var viewModel: ExpertViewModel
|
||
let schema: RouterOSMenuSchema
|
||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Text(L10n.t(schema.displayName, appLanguage))
|
||
.appFont(.title2)
|
||
.bold()
|
||
Text(L10n.t(schema.category.rawValue, appLanguage))
|
||
.appFont(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Text(L10n.t(schema.explanation, appLanguage))
|
||
if let warning = schema.warning {
|
||
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
|
||
.appFont(.caption)
|
||
.foregroundStyle(.orange)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
if viewModel.isLoading {
|
||
ProgressView()
|
||
} else if let error = viewModel.loadError {
|
||
Text(error).foregroundStyle(.red)
|
||
} else if viewModel.items.isEmpty {
|
||
Text(L10n.t("Keine Einträge unter", appLanguage) + " \(schema.menuPath).").foregroundStyle(.secondary)
|
||
} else {
|
||
// `.id(viewModel.items.count)` forces SwiftUI to treat this as a fresh view
|
||
// identity whenever the count changes — without it, deleting an item (via the
|
||
// `.confirmationDialog` below, not a `.sheet`) updates `viewModel.items`
|
||
// correctly (confirmed live via a temporary debug print: item count goes
|
||
// 1 → 0) but the rendered list still showed the removed row until switching
|
||
// tabs and back forced a full remount. Add/edit never hit this because their
|
||
// `.sheet(item:)` dismissal already forces a remount of this view on its own;
|
||
// delete's confirmationDialog doesn't tear this view down at all, so nothing
|
||
// was forcing SwiftUI to re-diff the Section in place.
|
||
ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in
|
||
HStack {
|
||
Button {
|
||
viewModel.startEditing(item)
|
||
} label: {
|
||
Text(rowLabel(for: item))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer()
|
||
// Not ".swipeActions" — that's an iOS/iPadOS gesture with no
|
||
// equivalent on macOS List rows, so it never showed anything here.
|
||
if !schema.isSingleton {
|
||
Button(role: .destructive) {
|
||
viewModel.pendingRemoval = item
|
||
} label: {
|
||
Image(systemName: "trash")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(L10n.t("Eintrag löschen", appLanguage))
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.listRowBackground(TableZebra.color(for: index))
|
||
}
|
||
}
|
||
} header: {
|
||
HStack {
|
||
Text(L10n.t(schema.isSingleton ? "Einstellungen" : "Einträge", appLanguage))
|
||
Spacer()
|
||
if !schema.isSingleton {
|
||
Button(L10n.t("Neu hinzufügen", appLanguage)) { viewModel.startNewItem() }
|
||
}
|
||
}
|
||
}
|
||
.id(viewModel.items.count)
|
||
}
|
||
.formStyle(.grouped)
|
||
// See DevicesView's identical fix: `.formStyle(.grouped)` paints an opaque background
|
||
// over each Section's rows, hiding `.listRowBackground` (Zebra-Streifen) underneath it.
|
||
.scrollContentBackground(.hidden)
|
||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.schema(schema.menuPath)) } }
|
||
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
|
||
.task(id: schema.id) { await viewModel.reloadItems() }
|
||
.sheet(item: $viewModel.editingItem) { item in
|
||
ExpertItemEditView(
|
||
viewModel: viewModel,
|
||
schema: schema,
|
||
isNew: item.id.isEmpty
|
||
)
|
||
}
|
||
.confirmationDialog(
|
||
L10n.t("Eintrag wirklich löschen?", appLanguage),
|
||
isPresented: Binding(
|
||
get: { viewModel.pendingRemoval != nil },
|
||
set: { if !$0 { viewModel.pendingRemoval = nil } }
|
||
),
|
||
presenting: viewModel.pendingRemoval
|
||
) { item in
|
||
Button(L10n.t("Löschen", appLanguage), role: .destructive) {
|
||
Task { await viewModel.confirmRemoval(item) }
|
||
}
|
||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||
} message: { item in
|
||
Text(rowLabel(for: item))
|
||
}
|
||
}
|
||
|
||
private func rowLabel(for item: RouterOSMenuItem) -> String {
|
||
let columns = schema.listColumns.isEmpty ? Array(item.fields.keys.sorted()) : schema.listColumns
|
||
let parts = columns.compactMap { key in item.fields[key].map { "\(key)=\($0)" } }
|
||
return parts.isEmpty ? item.id : parts.joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
/// Add/edit sheet: curated fields (with tooltips) from the schema, plus a free-form "weitere
|
||
/// Parameter" list for anything the schema doesn't curate — so every field RouterOS actually
|
||
/// supports stays reachable even where this app hasn't described it yet. Not private: the
|
||
/// Übersicht tab reuses this same sheet (with its own `ExpertViewModel`) so clicking an editable
|
||
/// node there writes back through the exact same form/command path as the Experte-Tab.
|
||
struct ExpertItemEditView: View {
|
||
@ObservedObject var viewModel: ExpertViewModel
|
||
let schema: RouterOSMenuSchema
|
||
let isNew: Bool
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var showApplyConfirmation = false
|
||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||
|
||
var body: some View {
|
||
// Header fixed outside the `Form` (not a `Section`, which would scroll away with the
|
||
// content) — same layout/behavior as the Übersicht focus popup's header (`OverviewView
|
||
// .focusPanel`): title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
|
||
// directly below, per explicit request to keep every popup's close button consistent.
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text(L10n.t(schema.displayName, appLanguage)).appFont(.headline)
|
||
Spacer()
|
||
Button {
|
||
viewModel.cancelEditing()
|
||
dismiss()
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(L10n.t("Schließen, ohne zu speichern", appLanguage))
|
||
}
|
||
.padding(10)
|
||
|
||
Divider()
|
||
|
||
Form {
|
||
ForEach(schema.fields) { field in
|
||
fieldEditor(for: field)
|
||
}
|
||
|
||
// Gleiche "Port-Konflikt-Prüfung" wie im Einrichten-Assistenten (LAN-Schritt,
|
||
// found.md #1), hier auf das `.interfacePick`-Feld dieses Schemas angewendet — per
|
||
// explizitem Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert
|
||
// anwenden ... mit allen Warnungen").
|
||
if let conflict = viewModel.interfacePortConflict {
|
||
PortConflictWarningView(
|
||
conflict: conflict,
|
||
isAcknowledged: viewModel.acknowledgedInterfacePortConflict,
|
||
appLanguage: appLanguage,
|
||
immediateApply: true,
|
||
onConfirm: { viewModel.acknowledgeInterfacePortConflict() }
|
||
)
|
||
} else if viewModel.isCheckingInterfacePortConflict {
|
||
HStack {
|
||
ProgressView().controlSize(.small)
|
||
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Section(LocalizedStringKey(L10n.t("Weitere Parameter (frei)", appLanguage))) {
|
||
Text(L10n.t("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.", appLanguage))
|
||
.appFont(.caption)
|
||
.foregroundStyle(.secondary)
|
||
// Zwei Spalten statt einer Zeile pro Parameter — bei Menüs mit vielen ungekuratierten
|
||
// RouterOS-Feldern (z.B. "Alle Interfaces (generisch)", wo Ethernet-Ports gut 30+
|
||
// Extra-Parameter mitbringen) halbiert das die Scroll-Länge dieses Formulars.
|
||
// Eigene Grid-Spalten für Parameter/Wert (statt eines TextFields pro Zeilenhälfte):
|
||
// `TextField(titleKey:text:)` zeigt den Titel auf macOS als feste Beschriftung vor
|
||
// dem Wert an (kein iOS-Platzhalter, der beim Tippen verschwindet) — bei 30+ Zeilen
|
||
// fraß "Parameter"/"Wert" so bei jeder Zeile erneut Breite, während an den äußeren
|
||
// Rändern Platz ungenutzt blieb (live per Screenshot bestätigt). Jetzt eine einmalige
|
||
// Kopfzeile statt Pro-Zeile-Label, leere TextField-Titel, Wert-Spalten mit
|
||
// `maxWidth: .infinity` gemäß Inhalt.
|
||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 14) {
|
||
GridRow {
|
||
extraColumnHeader("Parameter")
|
||
extraColumnHeader("Wert")
|
||
Color.clear.gridCellUnsizedAxes(.horizontal)
|
||
extraColumnHeader("Parameter")
|
||
extraColumnHeader("Wert")
|
||
}
|
||
ForEach(Array(stride(from: 0, to: viewModel.extraFields.count, by: 2).enumerated()), id: \.element) { rowIndex, start in
|
||
GridRow {
|
||
extraKeyField(at: start)
|
||
extraValueField(at: start)
|
||
if start + 1 < viewModel.extraFields.count {
|
||
Rectangle()
|
||
.fill(Color.secondary.opacity(0.25))
|
||
.frame(width: 1)
|
||
.frame(maxHeight: .infinity)
|
||
extraKeyField(at: start + 1)
|
||
extraValueField(at: start + 1)
|
||
} else {
|
||
Color.clear
|
||
Color.clear
|
||
Color.clear
|
||
}
|
||
}
|
||
.background(TableZebra.color(for: rowIndex))
|
||
Divider()
|
||
.gridCellColumns(5)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.bottom, 12)
|
||
Button(L10n.t("Parameter hinzufügen", appLanguage)) {
|
||
viewModel.extraFields.append(.init())
|
||
}
|
||
}
|
||
|
||
if let command = viewModel.pendingCommand {
|
||
Section(LocalizedStringKey(L10n.t("Wird ausgeführt", appLanguage))) {
|
||
Text(command.cliLine)
|
||
.appFont(.caption, design: .monospaced)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if let error = viewModel.applyError {
|
||
Text(error).foregroundStyle(.red)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
Spacer()
|
||
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
|
||
showApplyConfirmation = true
|
||
}
|
||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil || viewModel.hasUnresolvedInterfacePortConflict)
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
}
|
||
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
|
||
.onAppear {
|
||
viewModel.checkInterfacePortConflict()
|
||
}
|
||
.onChange(of: viewModel.formValues[interfaceFieldKey ?? "", default: ""]) { _, _ in
|
||
viewModel.checkInterfacePortConflict()
|
||
}
|
||
.confirmationDialog(
|
||
L10n.t("Jetzt am Router anwenden?", appLanguage),
|
||
isPresented: $showApplyConfirmation,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
|
||
Task {
|
||
await viewModel.saveEditingItem()
|
||
if viewModel.applyError == nil { dismiss() }
|
||
}
|
||
}
|
||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||
} message: {
|
||
if let command = viewModel.pendingCommand {
|
||
Text(command.cliLine)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The current schema's `.interfacePick` field key, if it has one — mirrors
|
||
/// `ExpertViewModel.interfaceFieldKey`, kept here too since `.onChange` needs a concrete
|
||
/// key path to observe (a schema only ever has at most one such field).
|
||
private var interfaceFieldKey: String? {
|
||
schema.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key
|
||
}
|
||
|
||
private func extraColumnHeader(_ key: String) -> some View {
|
||
Text(L10n.t(key, appLanguage))
|
||
.appFont(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
private func extraKeyField(at index: Int) -> some View {
|
||
TextField("", text: $viewModel.extraFields[index].key)
|
||
.textFieldStyle(.plain)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func extraValueField(at index: Int) -> some View {
|
||
HStack {
|
||
TextField("", text: $viewModel.extraFields[index].value)
|
||
.textFieldStyle(.plain)
|
||
.frame(maxWidth: .infinity)
|
||
Button(role: .destructive) {
|
||
let id = viewModel.extraFields[index].id
|
||
viewModel.extraFields.removeAll { $0.id == id }
|
||
} label: {
|
||
Image(systemName: "minus.circle")
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func fieldEditor(for field: RouterOSFieldSchema) -> some View {
|
||
let binding = Binding<String>(
|
||
get: { viewModel.formValues[field.key] ?? "" },
|
||
set: { viewModel.formValues[field.key] = $0 }
|
||
)
|
||
let label = L10n.t(field.label, appLanguage)
|
||
Group {
|
||
switch field.kind {
|
||
case .text, .int:
|
||
TextField(label, text: binding)
|
||
case .bool:
|
||
Toggle(label, isOn: Binding(
|
||
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
|
||
set: { binding.wrappedValue = $0 ? "yes" : "no" }
|
||
))
|
||
case .enumPick(let options):
|
||
Picker(label, selection: binding) {
|
||
Text("–").tag("")
|
||
ForEach(options, id: \.self) { option in
|
||
Text(option).tag(option)
|
||
}
|
||
}
|
||
case .interfacePick:
|
||
Picker(label, selection: binding) {
|
||
Text("–").tag("")
|
||
ForEach(viewModel.liveInterfaceNames, id: \.self) { name in
|
||
Text(name).tag(name)
|
||
}
|
||
}
|
||
case .menuItemPick(let menuPath, _, _):
|
||
let options = viewModel.crossReferenceOptions[menuPath] ?? []
|
||
Picker(label, selection: binding) {
|
||
Text("–").tag("")
|
||
ForEach(options, id: \.self) { option in
|
||
Text(option).tag(option)
|
||
}
|
||
}
|
||
case .duration:
|
||
HStack {
|
||
Text(label)
|
||
Spacer()
|
||
DurationFieldEditor(value: binding)
|
||
}
|
||
}
|
||
}
|
||
.help(L10n.t(field.help.isEmpty ? field.label : field.help, appLanguage))
|
||
}
|
||
}
|
||
|
||
/// Day/hour/minute/second steppers for a RouterOS time value (e.g. "1d12h30m") instead of a
|
||
/// bare text field where the suffix syntax isn't obvious. Reads any combination of RouterOS'
|
||
/// "<number><unit>" tokens (or a bare number, which RouterOS treats as seconds) and always
|
||
/// writes back the explicit suffixed form.
|
||
private struct DurationFieldEditor: View {
|
||
@Binding var value: String
|
||
|
||
@State private var days = 0
|
||
@State private var hours = 0
|
||
@State private var minutes = 0
|
||
@State private var seconds = 0
|
||
@State private var hasInitialized = false
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
component("T", $days, 0...365)
|
||
component("Std", $hours, 0...23)
|
||
component("Min", $minutes, 0...59)
|
||
component("Sek", $seconds, 0...59)
|
||
}
|
||
.onAppear {
|
||
guard !hasInitialized else { return }
|
||
hasInitialized = true
|
||
parse(value)
|
||
}
|
||
.onChange(of: days) { _, _ in commit() }
|
||
.onChange(of: hours) { _, _ in commit() }
|
||
.onChange(of: minutes) { _, _ in commit() }
|
||
.onChange(of: seconds) { _, _ in commit() }
|
||
}
|
||
|
||
private func component(_ unit: String, _ binding: Binding<Int>, _ range: ClosedRange<Int>) -> some View {
|
||
VStack(spacing: 2) {
|
||
Stepper(value: binding, in: range) {
|
||
Text("\(binding.wrappedValue)")
|
||
.monospacedDigit()
|
||
.frame(minWidth: 26)
|
||
}
|
||
Text(unit)
|
||
.appFont(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
private func parse(_ raw: String) {
|
||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||
guard !trimmed.isEmpty else { return }
|
||
if let bareSeconds = Int(trimmed) {
|
||
seconds = bareSeconds % 60
|
||
minutes = (bareSeconds / 60) % 60
|
||
hours = (bareSeconds / 3600) % 24
|
||
days = bareSeconds / 86400
|
||
return
|
||
}
|
||
guard let regex = try? NSRegularExpression(pattern: #"(\d+)([dhms])"#) else { return }
|
||
let nsText = trimmed as NSString
|
||
for match in regex.matches(in: trimmed, range: NSRange(location: 0, length: nsText.length)) {
|
||
guard let amount = Int(nsText.substring(with: match.range(at: 1))) else { continue }
|
||
switch nsText.substring(with: match.range(at: 2)) {
|
||
case "d": days = amount
|
||
case "h": hours = amount
|
||
case "m": minutes = amount
|
||
case "s": seconds = amount
|
||
default: break
|
||
}
|
||
}
|
||
}
|
||
|
||
private func commit() {
|
||
var parts: [String] = []
|
||
if days > 0 { parts.append("\(days)d") }
|
||
if hours > 0 { parts.append("\(hours)h") }
|
||
if minutes > 0 { parts.append("\(minutes)m") }
|
||
if seconds > 0 { parts.append("\(seconds)s") }
|
||
value = parts.joined()
|
||
}
|
||
}
|