import SwiftUI import OnyxDesign import OnyxNotch import OnyxWidgetKit import CalendarProvider import ShelfProvider import OnyxMenuBar import MetricsProvider import OnyxHelperProtocol import WeatherProvider enum SettingsTab: Hashable { case widgets, display, sources, menubar, fans, permissions } struct SettingsView: View { @Bindable var model: AppModel let calendarModel: CalendarModel let weatherModel: WeatherModel let metricsModel: MetricsModel? let shelfStore: ShelfStore? let fanControl: FanControl let launchAtLogin: LaunchAtLogin let showOnboarding: () -> Void @Binding var selectedTab: SettingsTab var body: some View { TabView(selection: $selectedTab) { WidgetSettings(model: model, shelfStore: shelfStore) .tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") } .tag(SettingsTab.widgets) DisplaySettings(model: model) .tabItem { Label("settings.tab.display", systemImage: "macbook") } .tag(SettingsTab.display) CalendarSourceSettings(model: calendarModel) .tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") } .tag(SettingsTab.sources) MenuBarSettings(model: model, metricsModel: metricsModel) .tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") } .tag(SettingsTab.menubar) FanSettingsView(control: fanControl) .tabItem { Label("settings.tab.fans", systemImage: "fan") } .tag(SettingsTab.fans) PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel, launchAtLogin: launchAtLogin, showOnboarding: showOnboarding) .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tag(SettingsTab.permissions) } .frame(width: 520, height: 420) } } // MARK: - Berechtigungen /// Zeigt den Zustand, statt ihn nur spürbar zu machen. /// /// Eine verweigerte Berechtigung äußert sich sonst als leeres Widget, und der /// Weg zurück ist nicht offensichtlich: hat macOS die Anfrage einmal /// abgelehnt bekommen, fragt es nie wieder — es bleibt nur der Gang in die /// Systemeinstellungen. private struct PermissionSettings: View { let calendarModel: CalendarModel let weatherModel: WeatherModel let launchAtLogin: LaunchAtLogin let showOnboarding: () -> Void @State private var calendarGranted = false private var locationState: PermissionRow.State { switch weatherModel.locationAuthorization { case .granted: .granted case .denied: .denied case .undetermined: .undetermined } } var body: some View { Form { Section("settings.general") { Toggle(isOn: Binding( get: { launchAtLogin.isEnabled }, set: { _ = launchAtLogin.set($0) })) { Text("settings.launchAtLogin") } .disabled(launchAtLogin.needsSystemSettings) // Der Zustand, der sonst in eine Schleife führt: der Schalter // steht auf aus, Drücken bewirkt nichts, weil das System den // Eintrag blockiert. Also steht hier, wo es weitergeht. if launchAtLogin.needsSystemSettings { HStack { Text("settings.launchAtLogin.blocked") .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer() Button("settings.permissions.openSystem") { launchAtLogin.openSystemSettings() } } } HStack { Spacer() Button("settings.onboarding.show", action: showOnboarding) .buttonStyle(.borderless) } } Section { PermissionRow( title: "settings.permissions.calendar", symbol: "calendar", state: calendarGranted ? .granted : (calendarModel.authorization == .denied ? .denied : .undetermined), explanation: "settings.permissions.calendar.why", pane: .calendar, request: { calendarGranted = await calendarModel.requestAccessNow() }) } Section { PermissionRow( title: "settings.permissions.location", symbol: "location", // Direkt aus dem beobachtbaren Wert: der Zustand ändert // sich, während dieses Fenster offen steht. state: locationState, explanation: "settings.permissions.location.why", pane: .location, request: { weatherModel.requestLocationAuthorization() }) } } .formStyle(.grouped) .padding() .task { launchAtLogin.refresh() calendarGranted = calendarModel.hasAccess guard !calendarGranted, calendarModel.authorization == .undetermined else { return } // Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den // Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung // wirkt asynchron. try? await Task.sleep(for: .milliseconds(400)) calendarGranted = await calendarModel.requestAccessNow() } } } /// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt. /// /// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist /// die erste Fassung gescheitert: „noch nie gefragt" lässt sich mit einer /// Anfrage lösen, „abgelehnt" nur über die Systemeinstellungen, und „erteilt" /// braucht gar nichts. private struct PermissionRow: View { enum State { case granted, denied, undetermined } enum Pane { case calendar, location /// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer /// Knopf, der immer im Kalender landet, ist schlimmer als keiner. var url: URL? { switch self { case .calendar: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") case .location: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") } } } let title: LocalizedStringKey let symbol: String let state: State let explanation: LocalizedStringKey let pane: Pane let request: () async -> Void var body: some View { HStack { Label(title, systemImage: symbol) Spacer() switch state { case .granted: Label("settings.permissions.granted", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) .labelStyle(.titleAndIcon) case .undetermined: Button("settings.permissions.request") { Task { await request() } } case .denied: // macOS fragt nach einer Ablehnung nicht mehr — ein // Anfragen-Knopf wäre hier eine Lüge. Button("settings.permissions.openSystem") { if let url = pane.url { NSWorkspace.shared.open(url) } } } } Text(explanation) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } // MARK: - Menüleiste /// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart. /// /// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine /// Zumutung, und die Menüleiste ist ohnehin knapp — auf einem Notch-Display /// blockiert die Kamera zusätzlich die Mitte. private struct MenuBarSettings: View { @Bindable var model: AppModel let metricsModel: MetricsModel? private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [ ("metric.cpu", "metric.cpu", "cpu"), ("metric.gpu", "metric.gpu", "cpu.fill"), ("metric.memory", "metric.memory", "memorychip"), ("metric.battery", "metric.battery", "battery.100"), ("metric.temperature", "metric.sensors", "thermometer"), ("network", "network.name", "network"), ("audio", "mixer.menubar.name", "slider.horizontal.3"), ("fans", "widget.fans.name", "fan"), ] var body: some View { VStack(alignment: .leading, spacing: 12) { Text("settings.menubar.hint") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) List { ForEach(Self.modules, id: \.id) { module in ModuleRow(model: model, id: module.id, label: module.label, symbol: module.symbol, metricsModel: metricsModel) } } .listStyle(.inset) } .padding() // Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul // ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten. .id(model.menuBarRevision) } } private struct ModuleRow: View { @Bindable var model: AppModel let id: String let label: LocalizedStringKey let symbol: String let metricsModel: MetricsModel? /// Welche Darstellungen dieses Modul beherrscht. static func presentations(for id: String) -> [MenuBarPresentation] { switch id { // Ein Mischpult hat keinen einzelnen Messwert. case "audio": [] // Drehzahl als Zahl oder nur das Symbol — ein Verlauf der Drehzahl // sagt in 28 Punkten Breite nichts. case "fans": [.value, .symbol] // Der Akku kann alles, und gerade dort will man Symbol oder Kürzel: // eine nackte Prozentzahl neben vier anderen sagt nichts. case "metric.battery": [.symbolAndValue, .labelAndValue, .value, .symbol, .graph, .valueAndGraph] default: MenuBarPresentation.allCases } } /// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk. private var metricKind: MetricKind? { guard id.hasPrefix("metric.") else { return nil } return MetricKind(rawValue: String(id.dropFirst("metric.".count))) } var body: some View { let settings = model.menuBarSettings(for: id) HStack { Toggle(isOn: Binding( get: { settings.isEnabled }, set: { enabled in var updated = settings updated.isEnabled = enabled model.setMenuBarSettings(updated, for: id) })) { Label(label, systemImage: symbol) } Spacer() // Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte // stammen aus derselben Messung — ein zweites Element in der // ohnehin knappen Menüleiste wäre Verschwendung. if let metric = metricKind, metric.supportsDisplayModes, let metricsModel { Picker("", selection: Binding( get: { metricsModel.displayMode(for: metric) }, set: { metricsModel.setDisplayMode($0, for: metric) })) { Text("metric.mode.usage").tag(MetricDisplayMode.usage) Text("metric.mode.temperature").tag(MetricDisplayMode.temperature) } .labelsHidden() .frame(width: 110) .disabled(!settings.isEnabled) } // Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat // keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen // könnte — eine Auswahl ohne Wirkung wäre eine Falle. if !Self.presentations(for: id).isEmpty { Picker("", selection: Binding( get: { settings.presentation }, set: { presentation in var updated = settings updated.presentation = presentation model.setMenuBarSettings(updated, for: id) })) { ForEach(Self.presentations(for: id)) { presentation in Text(presentation.localizedName).tag(presentation) } } .labelsHidden() .frame(width: 150) .disabled(!settings.isEnabled) } } } } extension MenuBarPresentation { var localizedName: LocalizedStringKey { switch self { case .value: "settings.menubar.value" case .graph: "settings.menubar.graph" case .bars: "settings.menubar.bars" case .valueAndGraph: "settings.menubar.valueAndGraph" case .symbol: "settings.menubar.symbol" case .labelAndValue: "settings.menubar.labelAndValue" case .symbolAndValue: "settings.menubar.symbolAndValue" } } } // MARK: - Widgets private struct WidgetSettings: View { @Bindable var model: AppModel let shelfStore: ShelfStore? var body: some View { VStack(alignment: .leading, spacing: 12) { Text("settings.widgets.hint") .font(.callout) .foregroundStyle(.secondary) List { Section("settings.widgets.inPanel") { // Die Reihenfolge hier ist die Reihenfolge im Panel: die // Layout-Engine setzt die Widgets von oben links der Reihe // nach ein. ForEach(model.layout) { placement in if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) { PlacementRow(model: model, placement: placement, widget: widget) } } .onMove { model.move(fromOffsets: $0, toOffset: $1) } if model.layout.isEmpty { Text("settings.widgets.empty") .foregroundStyle(.secondary) } } let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) } if !available.isEmpty { Section("settings.widgets.available") { ForEach(available, id: \.id) { widget in HStack { Label(widget.displayName, systemImage: widget.symbolName) Spacer() Button("settings.widgets.add") { model.add(widget) } .buttonStyle(.borderless) } } } } if let shelfStore { Section("settings.shelf.title") { ShelfSettingsRows(store: shelfStore) } } } .listStyle(.inset) } .padding() } } /// Einstellungen der Ablage. /// /// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus: /// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein /// Papierkorb mit Zeitschaltuhr. private struct ShelfSettingsRows: View { @Bindable var store: ShelfStore private static let choices: [Int?] = [nil, 1, 7, 30, 90] var body: some View { Picker(selection: $store.autoCleanupDays) { ForEach(Self.choices, id: \.self) { days in if let days { Text("settings.shelf.days \(days)").tag(Optional(days)) } else { Text("settings.shelf.never").tag(Int?.none) } } } label: { Text("settings.shelf.cleanup") } HStack { Text("settings.shelf.count \(store.items.count)") .foregroundStyle(.secondary) Spacer() Button("settings.shelf.reveal") { store.revealInFinder() } .buttonStyle(.borderless) } } } private struct PlacementRow: View { @Bindable var model: AppModel let placement: WidgetPlacement let widget: any OnyxWidget var body: some View { HStack { Label(widget.displayName, systemImage: widget.symbolName) Spacer() Button { model.remove(placement.widgetID) } label: { Image(systemName: "minus.circle") } .buttonStyle(.borderless) .help("settings.widgets.remove") } } } // MARK: - Anzeige private struct DisplaySettings: View { @Bindable var model: AppModel var body: some View { Form { Picker("settings.display.policy", selection: $model.displayPolicy) { Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly) Text("settings.display.all").tag(DisplayPolicy.allDisplays) } .pickerStyle(.radioGroup) Text("settings.display.hint") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Divider() Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon) Text("settings.display.menuBarIcon.hint") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Divider() Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon) Text("settings.display.dockIcon.hint") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .formStyle(.grouped) .padding() } }