Files
RouterOS/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift
T
KayandClaude Sonnet 5 3a26f50800 M31: Handbuch in der App (Textanker, DE+EN)
"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und
allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und
springen per Textanker direkt zur passenden Manual-Stelle.

build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES-
Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller
Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache
automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs-
Dictionary wiederverwendet wird (714 Einträge geparst) statt einer
zweiten, separat gepflegten Übersetzung.

Live bestätigt (DE und EN).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:15:29 +02:00

407 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
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)
}
}
}
.formStyle(.grouped)
}
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
.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)
}
}
}
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()
}
}