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( 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' /// "" 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, _ range: ClosedRange) -> 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() } }