import SwiftUI import OnyxDesign import OnyxNotch import OnyxWidgetKit import CalendarProvider enum SettingsTab: Hashable { case widgets, display, permissions } struct SettingsView: View { @Bindable var model: AppModel let calendarModel: CalendarModel @Binding var selectedTab: SettingsTab var body: some View { TabView(selection: $selectedTab) { WidgetSettings(model: model) .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) PermissionSettings(calendarModel: calendarModel) .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 @State private var granted = false var body: some View { Form { Section { HStack { Label("settings.permissions.calendar", systemImage: "calendar") Spacer() if granted { Label("settings.permissions.granted", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) .labelStyle(.titleAndIcon) } else { Button("settings.permissions.request") { Task { let ok = await calendarModel.requestAccessNow() granted = ok // macOS fragt nach einer Ablehnung nicht erneut. // Dann hilft nur noch der Weg über die // Systemeinstellungen — also dorthin führen. if !ok { openPrivacySettings() } } } } } Text("settings.permissions.calendar.why") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Section { Button("settings.permissions.openSystem") { openPrivacySettings() } } } .formStyle(.grouped) .padding() .task { granted = calendarModel.hasAccess guard !granted, !didAutoRequest, calendarModel.authorization == .undetermined else { return } didAutoRequest = true // Kurz warten, bis das Fenster wirklich vorne steht. TCC zeigt den // Dialog nur, wenn eine App im Vordergrund ist, an die er gehören // kann — und Aktivierung wirkt asynchron. Ohne diese Pause fragt // Onyx, während es formal noch im Hintergrund liegt, und die // Anfrage verpufft kommentarlos. try? await Task.sleep(for: .milliseconds(400)) granted = await calendarModel.requestAccessNow() } } @State private var didAutoRequest = false private func openPrivacySettings() { guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return } NSWorkspace.shared.open(url) } } // MARK: - Widgets private struct WidgetSettings: View { @Bindable var model: AppModel 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) } } } } } .listStyle(.inset) } .padding() } } 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() // Nur die Größen anbieten, die das Widget auch sinnvoll ausfüllt. // Ein Mini-Monat in 1×1 wäre unleserlich. Picker("", selection: Binding( get: { placement.size }, set: { model.setSize($0, for: placement.widgetID) })) { ForEach(widget.supportedSizes) { size in Text(size.localizedName).tag(size) } } .labelsHidden() .frame(width: 130) 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) } .formStyle(.grouped) .padding() } } extension WidgetSize { var localizedName: LocalizedStringKey { switch self { case .small: "settings.size.small" case .medium: "settings.size.medium" case .large: "settings.size.large" case .wide: "settings.size.wide" } } }