M23: Experte-Tab-Sektionsüberschriften prominenter + einklappbar

Section-Header größer/fett mit adaptivem accentColor-Hintergrund statt
macOS' winzigem Kapitälchen-Default. Einklappbar per eigenem Chevron
(immer sichtbar, nicht macOS' Hover-only-Sidebar-Dreieck) statt
Section(isExpanded:), da SwiftUI dessen natives Disclosure-Verhalten
nicht anpassen lässt. Startet standardmäßig komplett zugeklappt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-16 11:03:39 +02:00
co-authored by Claude Sonnet 5
parent a0e7e53f22
commit 567feed93a
3 changed files with 114 additions and 27 deletions
+29
View File
@@ -1389,6 +1389,35 @@ in eine private `resetToInitialState()` plus zwei benannte Wrapper
Aufrufkontext (erfolgreicher Apply vs. Nutzer bricht ab) unter dem Aufrufkontext (erfolgreicher Apply vs. Nutzer bricht ab) unter dem
passenden Namen lesbar. Live bestätigt ("sieht gut aus"). passenden Namen lesbar. Live bestätigt ("sieht gut aus").
**M23: Experte-Tab-Sektionsüberschriften prominenter + einklappbar**
(2026-09-16, Nutzerwunsch: "die Sektionsüberschriften ... müssen
prominenter sein und eingefärbt, bitte größer machen und passend
farblich hinterlegen (darkmode beachten)", danach "die einzelnen
Sektion einklappbar machen"). Zwei Schritte:
1. `Section("...")`s Standard-Header (macOS' winzige graue Kapitälchen-
Beschriftung) durch eigenen `Text` ersetzt: größer/fett
(`.title3.bold()`), Hintergrund `Color.accentColor.opacity(0.18)`
statt eines festen RGB-Werts, damit Light/Dark Mode automatisch
passend kontrastieren — `.textCase(nil)` nötig, sonst erzwingt macOS
trotzdem wieder seine eigene Kapitälchen-Darstellung über dem
eigenen View.
2. Einklappbar gemacht — erster Versuch mit SwiftUIs eingebautem
`Section(isExpanded:)` (native Disclosure-Dreieck), vom Nutzer
sofort korrigiert: "der Pfeil sollte immer zu sehen sein und nicht
erst beim Hovern" — das native macOS-Sidebar-Dreieck ist genau wie
in Finder standardmäßig Hover-only, keine SwiftUI-Option dafür
vorgesehen. Fix: eigenes Chevron-Icon (`chevron.right`, per
`.rotationEffect` gedreht) direkt im selbstgebauten Header, ganzer
Header als `Button` klickbar; `Section(isExpanded:)`-API komplett
fallengelassen zugunsten eines `if isExpanded { ForEach(...) }`
innerhalb eines schlichten `Section { }`, gesteuert über eigenen
`@State`/Binding (`collapsedCategories: Set<RouterOSMenuCategory>`
für die neun Kategorien, `isCustomPathExpanded: Bool` für "Eigener
Menüpfad"). Auf Nutzerwunsch ("standardmäßig sollten beim Öffnen des
Experte-Tab alle Sektion zugeklappt sein") startet alles zugeklappt.
`.listStyle(.sidebar)` an der `List` ergänzt, für den nativen
macOS-Sidebar-Look. Live bestätigt ("sehr gut, bin sehr zufrieden").
## Nächste Schritte ## Nächste Schritte
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~, 1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~,
+2 -1
View File
@@ -151,7 +151,8 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert | | M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert |
| M20 | LAN-Scanner: Umbenennung, Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan) | ✅ live verifiziert | | M20 | LAN-Scanner: Umbenennung, Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan) | ✅ live verifiziert |
| M21 | Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt (Einrichten/Übersicht/LAN-Scanner/Sicherungen) | ✅ live verifiziert | | M21 | Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt (Einrichten/Übersicht/LAN-Scanner/Sicherungen) | ✅ live verifiziert |
| M22 | REST-Transport (M7) erstmals live gegen Hardware verifiziert, 3 Bugs gefunden+gefixt | ✅ live verifiziert | | M22 | REST-Transport (M7) erstmals live gegen Hardware verifiziert, 4 Bugs gefunden+gefixt | ✅ live verifiziert |
| M23 | Experte-Tab: Sektionsüberschriften prominenter+eingefärbt, einklappbar (Standard: zugeklappt) | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen | | — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
@@ -4,6 +4,11 @@ struct ExpertView: View {
@ObservedObject var connectionService: ConnectionService @ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: ExpertViewModel @StateObject private var viewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de" @AppStorage("appLanguage") private var appLanguage: String = "de"
/// 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) { init(connectionService: ConnectionService) {
self.connectionService = connectionService self.connectionService = connectionService
@@ -24,40 +29,58 @@ struct ExpertView: View {
ForEach(RouterOSMenuCategory.allCases) { category in ForEach(RouterOSMenuCategory.allCases) { category in
let schemas = RouterOSSchemaCatalog.schemas(in: category) let schemas = RouterOSSchemaCatalog.schemas(in: category)
if !schemas.isEmpty { if !schemas.isEmpty {
Section(LocalizedStringKey(L10n.t(category.rawValue, appLanguage))) { let isExpanded = !collapsedCategories.contains(category)
ForEach(schemas) { schema in Section {
let isSelected = viewModel.selectedSchema?.menuPath == schema.menuPath if isExpanded {
Button { ForEach(schemas) { schema in
viewModel.open(schema) let isSelected = viewModel.selectedSchema?.menuPath == schema.menuPath
} label: { Button {
Text(L10n.t(schema.displayName, appLanguage)) viewModel.open(schema)
.frame(maxWidth: .infinity, alignment: .leading) } label: {
.padding(.vertical, 4) Text(L10n.t(schema.displayName, appLanguage))
.padding(.horizontal, 6) .frame(maxWidth: .infinity, alignment: .leading)
.background( .padding(.vertical, 4)
RoundedRectangle(cornerRadius: 6) .padding(.horizontal, 6)
.fill(isSelected ? Color.blue.opacity(0.25) : Color.clear) .background(
) RoundedRectangle(cornerRadius: 6)
.contentShape(Rectangle()) .fill(isSelected ? Color.blue.opacity(0.25) : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
} header: {
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded) {
if isExpanded {
collapsedCategories.insert(category)
} else {
collapsedCategories.remove(category)
} }
.buttonStyle(.plain)
} }
} }
} }
} }
Section(LocalizedStringKey(L10n.t("Eigener Menüpfad", appLanguage))) { Section {
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)) if isCustomPathExpanded {
.font(.caption) 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))
.foregroundStyle(.secondary) .font(.caption)
TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath) .foregroundStyle(.secondary)
.help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage)) TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath)
TextField(L10n.t("REST-Pfad, z.B. ip/firewall/filter", appLanguage), text: $viewModel.customRestPath) .help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage))
.help(L10n.t("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".", appLanguage)) TextField(L10n.t("REST-Pfad, z.B. ip/firewall/filter", appLanguage), text: $viewModel.customRestPath)
Button(L10n.t("Öffnen", appLanguage)) { viewModel.openCustomPath() } .help(L10n.t("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".", appLanguage))
.disabled(viewModel.customMenuPath.isEmpty || viewModel.customRestPath.isEmpty) 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))) .navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
} detail: { } detail: {
if let schema = viewModel.selectedSchema { if let schema = viewModel.selectedSchema {
@@ -76,6 +99,40 @@ struct ExpertView: View {
} }
} }
} }
/// 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, onToggle: @escaping () -> Void) -> some View {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption.bold())
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
Text(title)
.font(.title3.bold())
.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 { #Preview {