"Wetter nicht abrufbar" war eine falsche Auskunft: abrufbar war es sehr wohl, es fehlte nur die Erlaubnis. Derselbe Fehlertyp wie zuvor beim Kalender — ein Zustand, den die App nicht kennt, landet im nächstbesten Sammelfall und schickt den Nutzer damit in die Irre. locationUndetermined ist jetzt ein eigener Zustand neben locationDenied. Der Unterschied ist nicht kosmetisch: bei undetermined lohnt eine Anfrage, bei denied fragt macOS nie wieder und es hilft nur der Weg über die Systemeinstellungen oder ein fest gewählter Ort. Beide Fälle führen jetzt dorthin, wo es weitergeht. Die Anfrage kommt aus dem Berechtigungen-Reiter, nicht aus dem Hintergrund — dieselbe Lehre wie beim Kalender: ohne Vordergrundfenster zeigt macOS keinen Dialog, und die App wirkt kaputt, ohne dass irgendwo ein Fehler steht. Aufgefallen war es daran, dass im Protokoll überhaupt kein Wetter-Eintrag stand. Der einzige Pfad zu "nicht abrufbar" ohne Protokolleintrag war der Standort — jetzt protokolliert auch der.
262 lines
9.5 KiB
Swift
262 lines
9.5 KiB
Swift
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 granted = false
|
||
@State private var locationGranted = 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 {
|
||
HStack {
|
||
Label("settings.permissions.location", systemImage: "location")
|
||
Spacer()
|
||
if locationGranted {
|
||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
.labelStyle(.titleAndIcon)
|
||
} else if weatherModel.locationIsDenied {
|
||
Button("settings.permissions.openSystem") { openLocationSettings() }
|
||
} else {
|
||
Button("settings.permissions.request") {
|
||
// Muss aus diesem Fenster kommen: macOS zeigt den
|
||
// Dialog nur, wenn eine App im Vordergrund ist.
|
||
weatherModel.requestLocationAuthorization()
|
||
}
|
||
}
|
||
}
|
||
Text("settings.permissions.location.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 openLocationSettings() {
|
||
guard let url = URL(string:
|
||
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") else { return }
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
|
||
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"
|
||
}
|
||
}
|
||
}
|