Files
RouterOS/RouterOSAssistant/Features/Expert/ExpertView.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

147 lines
8.4 KiB
Swift

import SwiftUI
struct ExpertView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
/// A category's presence in this set means "collapsed". Starts with every category
/// collapsed (Nutzerwunsch: "standardmäßig sollten beim Öffnen des Experte-Tab alle
/// Sektion zugeklappt sein") — click a header to fold its menu list open again.
@State private var collapsedCategories: Set<RouterOSMenuCategory> = Set(RouterOSMenuCategory.allCases)
@State private var isCustomPathExpanded = false
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: ExpertViewModel(connectionService: connectionService))
}
var body: some View {
Group {
if connectionService.credentials == nil {
ContentUnavailableView(
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
systemImage: "network.slash",
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
)
} else {
NavigationSplitView {
List {
ForEach(RouterOSMenuCategory.allCases) { category in
let schemas = RouterOSSchemaCatalog.schemas(in: category)
if !schemas.isEmpty {
let isExpanded = !collapsedCategories.contains(category)
Section {
if isExpanded {
ForEach(schemas) { schema in
let isSelected = viewModel.selectedSchema?.menuPath == schema.menuPath
Button {
viewModel.open(schema)
} label: {
Text(L10n.t(schema.displayName, appLanguage))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isSelected ? Color.blue.opacity(0.25) : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
} header: {
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded, categoryColor: colorTheme.color(for: category)) {
if isExpanded {
collapsedCategories.insert(category)
} else {
collapsedCategories.remove(category)
}
}
}
}
}
Section {
if isCustomPathExpanded {
Text(L10n.t("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath)
.help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage))
TextField(L10n.t("REST-Pfad, z.B. ip/firewall/filter", appLanguage), text: $viewModel.customRestPath)
.help(L10n.t("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".", appLanguage))
Button(L10n.t("Öffnen", appLanguage)) { viewModel.openCustomPath() }
.disabled(viewModel.customMenuPath.isEmpty || viewModel.customRestPath.isEmpty)
}
} header: {
sectionHeader(L10n.t("Eigener Menüpfad", appLanguage), isExpanded: isCustomPathExpanded) {
isCustomPathExpanded.toggle()
}
}
}
.listStyle(.sidebar)
.navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabExpert) } }
} detail: {
if let schema = viewModel.selectedSchema {
ExpertMenuDetailView(
viewModel: viewModel,
schema: schema
)
} else {
ContentUnavailableView(
LocalizedStringKey(L10n.t("Kein Menü ausgewählt", appLanguage)),
systemImage: "list.bullet.rectangle",
description: Text(L10n.t("Wähle links einen RouterOS-Bereich aus.", appLanguage))
)
}
}
}
}
}
/// Custom Section header — plain `Section("title")` rendered as macOS' default tiny
/// gray-caps sidebar label, too easy to miss when scanning nine menu categories (Nutzerwunsch:
/// "müssen prominenter sein und eingefärbt"). `.textCase(nil)` is required here, or macOS
/// forces its own uppercase-small-caps styling back on top of this custom view regardless of
/// the font/background set below. `Color.accentColor` (not a fixed RGB value) so the tint
/// stays visible and correctly contrasted in both light and dark mode automatically.
///
/// The chevron is drawn explicitly here rather than relying on `Section(isExpanded:)`'s
/// built-in disclosure triangle — that one is macOS sidebar-standard hover-only (matches
/// Finder), which the user explicitly didn't want ("der Pfeil sollte immer zu sehen sein und
/// nicht erst beim Hovern"). The whole header is a plain `Button` toggling `onToggle`, with
/// the section's own content shown/hidden by the caller via a plain `if isExpanded` instead
/// of the `Section(isExpanded:)` API, since that API owns its own (hover-only) chevron.
private func sectionHeader(_ title: String, isExpanded: Bool, categoryColor: Color? = nil, onToggle: @escaping () -> Void) -> some View {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.appFont(.caption, bold: true)
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
if let categoryColor {
Circle().fill(categoryColor).frame(width: 8, height: 8)
}
Text(title)
.appFont(.title3, bold: true)
.foregroundStyle(.primary)
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.18)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.textCase(nil)
}
}
#Preview {
ExpertView(connectionService: ConnectionService())
}