Files
RouterOS/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift
T
KayandClaude Sonnet 5 bf2ae58a20 Experte-Tab: ausführlichere Tooltips, Sektions-Überschrift, aktive Auswahl hervorgehoben
Alle RouterOSFieldSchema.help-Texte in RouterOSSchemaCatalog.swift
überarbeitet — jedes Adress-/Netz-/Bereichs-Feld hat jetzt ein
konkretes Beispiel (z.B. 192.168.88.1/24), vorher leere Hilfetexte
gefüllt.

Zusätzlich zwei UI-Wünsche umgesetzt: fette Überschrift (Menüname +
Kategorie) im Detailbereich, da vorher unklar war, in welcher Sektion
man sich befindet; und hellblaue Hervorhebung des aktiven Eintrags in
der linken Liste (per Zeileninhalt-Hintergrund statt
.listRowBackground, das vom macOS-Sidebar-Stil überschrieben wird).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
2026-09-15 11:16:10 +02:00

309 lines
12 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
var body: some View {
Form {
Section {
Text(schema.displayName)
.font(.title2)
.bold()
Text(schema.category.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
Text(schema.explanation)
if let warning = schema.warning {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
Section {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.loadError {
Text(error).foregroundStyle(.red)
} else if viewModel.items.isEmpty {
Text("Keine Einträge unter \(schema.menuPath).").foregroundStyle(.secondary)
} else {
ForEach(viewModel.items) { 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("Eintrag löschen")
}
}
.contentShape(Rectangle())
}
}
} header: {
HStack {
Text(schema.isSingleton ? "Einstellungen" : "Einträge")
Spacer()
if !schema.isSingleton {
Button("Neu hinzufügen") { viewModel.startNewItem() }
}
}
}
}
.formStyle(.grouped)
.navigationTitle(schema.displayName)
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
ExpertItemEditView(
viewModel: viewModel,
schema: schema,
isNew: item.id.isEmpty
)
}
.confirmationDialog(
"Eintrag wirklich löschen?",
isPresented: Binding(
get: { viewModel.pendingRemoval != nil },
set: { if !$0 { viewModel.pendingRemoval = nil } }
),
presenting: viewModel.pendingRemoval
) { item in
Button("Löschen", role: .destructive) {
Task { await viewModel.confirmRemoval(item) }
}
Button("Abbrechen", 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.
private struct ExpertItemEditView: View {
@ObservedObject var viewModel: ExpertViewModel
let schema: RouterOSMenuSchema
let isNew: Bool
@Environment(\.dismiss) private var dismiss
@State private var showApplyConfirmation = false
var body: some View {
Form {
ForEach(schema.fields) { field in
fieldEditor(for: field)
}
Section("Weitere Parameter (frei)") {
Text("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach($viewModel.extraFields) { $extra in
HStack {
TextField("Parameter", text: $extra.key)
TextField("Wert", text: $extra.value)
Button(role: .destructive) {
viewModel.extraFields.removeAll { $0.id == extra.id }
} label: {
Image(systemName: "minus.circle")
}
}
}
Button("Parameter hinzufügen") {
viewModel.extraFields.append(.init())
}
}
if let command = viewModel.pendingCommand {
Section("Wird ausgeführt") {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
if let error = viewModel.applyError {
Text(error).foregroundStyle(.red)
}
Section {
HStack {
Button("Abbrechen") {
viewModel.cancelEditing()
dismiss()
}
Spacer()
Button(isNew ? "Anlegen" : "Speichern") {
showApplyConfirmation = true
}
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
}
}
}
.formStyle(.grouped)
.frame(minWidth: 420, minHeight: 480)
.confirmationDialog(
"Jetzt am Router anwenden?",
isPresented: $showApplyConfirmation,
titleVisibility: .visible
) {
Button(isNew ? "Anlegen" : "Speichern") {
Task {
await viewModel.saveEditingItem()
if viewModel.applyError == nil { dismiss() }
}
}
Button("Abbrechen", role: .cancel) {}
} message: {
if let command = viewModel.pendingCommand {
Text(command.cliLine)
}
}
}
@ViewBuilder
private func fieldEditor(for field: RouterOSFieldSchema) -> some View {
let binding = Binding<String>(
get: { viewModel.formValues[field.key] ?? "" },
set: { viewModel.formValues[field.key] = $0 }
)
Group {
switch field.kind {
case .text, .int:
TextField(field.label, text: binding)
case .bool:
Toggle(field.label, isOn: Binding(
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
set: { binding.wrappedValue = $0 ? "yes" : "no" }
))
case .enumPick(let options):
Picker(field.label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .interfacePick:
Picker(field.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(field.label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .duration:
HStack {
Text(field.label)
Spacer()
DurationFieldEditor(value: binding)
}
}
}
.help(field.help.isEmpty ? field.label : field.help)
}
}
/// 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)
.font(.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()
}
}