Files
onyx/Onyx/SettingsView.swift
Guido Schmit c0874c83b7 Berechtigungen zeigen ihren Zustand live, Dock-Symbol wird wählbar
Die Einstellungen zeigten weiter "Anfragen", obwohl die Ortung längst lief:
authorizationStatus ist eine Abfrage, keine Beobachtung, und ich hatte sie
einmalig beim Öffnen gelesen. Jetzt meldet locationManagerDidChangeAuthorization
die Änderung, WeatherModel führt daraus einen beobachtbaren Wert, und die
Einstellungen aktualisieren sich, während das Fenster offen steht. Nach der
Zustimmung wird sofort geladen statt beim nächsten Anlauf.

Der Knopf "Systemeinstellungen öffnen" ganz unten zeigte fest auf den
Kalenderbereich — wer ihn beim Standort drückte, landete am falschen Ort. Es
gibt ihn nicht mehr; stattdessen führt jede Berechtigung in ihren eigenen
Bereich, und der Knopf erscheint nur im Zustand "abgelehnt". Bei "noch nie
gefragt" steht dort Anfragen, bei "erteilt" gar nichts: drei Zustände, drei
Antworten. Genau daran ist die erste Fassung gescheitert.

Dock-Symbol ist jetzt eine Einstellung, standardmäßig aus. Onyx läuft als
LSUIElement — das war Absicht und keine Auslassung: es hat kein Hauptfenster
und wäre im Dock ein Symbol, das beim Anklicken nichts öffnet, und es stünde im
Programmumschalter zwischen den Programmen, mit denen man arbeitet. Wer es
trotzdem will, schaltet es ein; die Wahl wird beim Start wieder angewendet.
2026-08-10 21:24:49 +02:00

282 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import OnyxDesign
import OnyxNotch
import OnyxWidgetKit
import CalendarProvider
import WeatherProvider
enum SettingsTab: Hashable {
case widgets, display, permissions
}
struct SettingsView: View {
@Bindable var model: AppModel
let calendarModel: CalendarModel
let weatherModel: WeatherModel
@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, weatherModel: weatherModel)
.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
@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 {
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 {
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: - 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)
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()
}
}
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"
}
}
}