diff --git a/HANDOFF.md b/HANDOFF.md index 4c39afa..9553329 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1389,6 +1389,35 @@ in eine private `resetToInitialState()` plus zwei benannte Wrapper Aufrufkontext (erfolgreicher Apply vs. Nutzer bricht ab) unter dem 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` + 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 1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~, diff --git a/README.md b/README.md index dccd6f8..27e98e9 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund. | M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ 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 | -| 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 | Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und diff --git a/RouterOSAssistant/Features/Expert/ExpertView.swift b/RouterOSAssistant/Features/Expert/ExpertView.swift index 7d6318b..417d5bb 100644 --- a/RouterOSAssistant/Features/Expert/ExpertView.swift +++ b/RouterOSAssistant/Features/Expert/ExpertView.swift @@ -4,6 +4,11 @@ struct ExpertView: View { @ObservedObject var connectionService: ConnectionService @StateObject private var viewModel: ExpertViewModel @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 = Set(RouterOSMenuCategory.allCases) + @State private var isCustomPathExpanded = false init(connectionService: ConnectionService) { self.connectionService = connectionService @@ -24,40 +29,58 @@ struct ExpertView: View { ForEach(RouterOSMenuCategory.allCases) { category in let schemas = RouterOSSchemaCatalog.schemas(in: category) if !schemas.isEmpty { - Section(LocalizedStringKey(L10n.t(category.rawValue, appLanguage))) { - 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()) + 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) { + if isExpanded { + collapsedCategories.insert(category) + } else { + collapsedCategories.remove(category) } - .buttonStyle(.plain) } } } } - Section(LocalizedStringKey(L10n.t("Eigener Menüpfad", appLanguage))) { - 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)) - .font(.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) + 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)) + .font(.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))) } detail: { 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 {