diff --git a/AppIcon.icon/Assets/quartz.svg b/AppIcon.icon/Assets/quartz.svg new file mode 100644 index 0000000..736677b --- /dev/null +++ b/AppIcon.icon/Assets/quartz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json new file mode 100644 index 0000000..21df5ad --- /dev/null +++ b/AppIcon.icon/icon.json @@ -0,0 +1,36 @@ +{ + "fill" : { + "automatic-gradient" : "display-p3:0.21900,0.30045,0.61471,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "quartz.svg", + "name" : "quartz", + "position" : { + "scale" : 1.39, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index d532984..64ef47b 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,458 +1,490 @@ { - "sourceLanguage" : "en", - "strings" : { - "menu.openPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel öffnen" + "sourceLanguage": "en", + "strings": { + "menu.openPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Panel" } } } }, - "menu.quit" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx beenden" + "menu.quit": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beenden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quit Onyx" + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit Onyx" } } } }, - "menu.settings" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einstellungen …" + "menu.settings": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" } } } }, - "settings.display.all" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allen Displays" + "settings.display.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allen Displays" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All displays" + "en": { + "stringUnit": { + "state": "translated", + "value": "All displays" } } } }, - "settings.display.builtInOnly" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur dem internen Display" + "settings.display.builtInOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem internen Display" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Built-in display only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in display only" } } } }, - "settings.display.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." + "settings.display.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." + "en": { + "stringUnit": { + "state": "translated", + "value": "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." } } } }, - "settings.display.menuBarIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol in der Menüleiste anzeigen" + "settings.display.menuBarIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol in der Menüleiste anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show icon in the menu bar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the menu bar" } } } }, - "settings.display.menuBarIcon.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." + "settings.display.menuBarIcon.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." + "en": { + "stringUnit": { + "state": "translated", + "value": "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." } } } }, - "settings.display.policy" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel anzeigen auf" + "settings.display.policy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel anzeigen auf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show panel on" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show panel on" } } } }, - "settings.permissions.calendar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "settings.permissions.calendar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" } } } }, - "settings.permissions.calendar.why" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben." + "settings.permissions.calendar.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." } } } }, - "settings.permissions.granted" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erteilt" + "settings.permissions.granted": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Granted" + "en": { + "stringUnit": { + "state": "translated", + "value": "Granted" } } } }, - "settings.permissions.openSystem" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "settings.permissions.openSystem": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "settings.permissions.request" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anfragen" + "settings.permissions.request": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfragen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Request" + "en": { + "stringUnit": { + "state": "translated", + "value": "Request" } } } }, - "settings.size.large" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Groß (2×2)" + "settings.size.large": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß (2×2)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Large (2×2)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Large (2×2)" } } } }, - "settings.size.medium" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mittel (2×1)" + "settings.size.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (2×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medium (2×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (2×1)" } } } }, - "settings.size.small" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klein (1×1)" + "settings.size.small": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein (1×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Small (1×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Small (1×1)" } } } }, - "settings.size.wide" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Breit (4×1)" + "settings.size.wide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breit (4×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wide (4×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide (4×1)" } } } }, - "settings.tab.display" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "settings.tab.display": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.tab.permissions" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berechtigungen" + "settings.tab.permissions": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permissions" + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" } } } }, - "settings.tab.widgets" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "settings.tab.widgets": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "en": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } } } }, - "settings.widgets.add" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinzufügen" + "settings.widgets.add": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" } } } }, - "settings.widgets.available" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verfügbar" + "settings.widgets.available": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfügbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Available" + "en": { + "stringUnit": { + "state": "translated", + "value": "Available" } } } }, - "settings.widgets.empty" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + "settings.widgets.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No widgets in the panel. It stays empty until you add one." + "en": { + "stringUnit": { + "state": "translated", + "value": "No widgets in the panel. It stays empty until you add one." } } } }, - "settings.widgets.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." + "settings.widgets.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." + "en": { + "stringUnit": { + "state": "translated", + "value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." } } } }, - "settings.widgets.inPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im Panel" + "settings.widgets.inPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Panel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In the panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "In the panel" } } } }, - "settings.widgets.remove" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus dem Panel entfernen" + "settings.widgets.remove": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus dem Panel entfernen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove from panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from panel" + } + } + } + }, + "settings.permissions.location": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" + } + } + } + }, + "settings.permissions.location.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index eb9e4ca..4134699 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -92,7 +92,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt // das Widget den Berechtigungshinweis statt einer leeren Liste. calendarModel.refresh() - guard !calendarModel.hasAccess else { return } + // Fenster öffnen, solange irgendeine Berechtigung noch aussteht. + let needsLocation = weatherModel?.locationIsUndetermined ?? false + guard !calendarModel.hasAccess || needsLocation else { return } // Nicht im Hintergrund anfragen. // @@ -177,8 +179,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func openSettings() { showSettings(tab: .widgets) } private func showSettings(tab: SettingsTab) { - guard let model, let calendarModel else { return } - settingsWindow.show(model: model, calendarModel: calendarModel, tab: tab) + guard let model, let calendarModel, let weatherModel else { return } + settingsWindow.show(model: model, calendarModel: calendarModel, + weatherModel: weatherModel, tab: tab) } @objc private func quit() { NSApp.terminate(nil) } diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 5e93e5b..b3103cc 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -3,6 +3,7 @@ import OnyxDesign import OnyxNotch import OnyxWidgetKit import CalendarProvider +import WeatherProvider enum SettingsTab: Hashable { case widgets, display, permissions @@ -11,6 +12,7 @@ enum SettingsTab: Hashable { struct SettingsView: View { @Bindable var model: AppModel let calendarModel: CalendarModel + let weatherModel: WeatherModel @Binding var selectedTab: SettingsTab var body: some View { @@ -21,7 +23,7 @@ struct SettingsView: View { DisplaySettings(model: model) .tabItem { Label("settings.tab.display", systemImage: "macbook") } .tag(SettingsTab.display) - PermissionSettings(calendarModel: calendarModel) + PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tag(SettingsTab.permissions) } @@ -39,7 +41,9 @@ struct SettingsView: View { /// 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 { @@ -70,6 +74,30 @@ private struct PermissionSettings: View { .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() } } @@ -94,6 +122,12 @@ private struct PermissionSettings: View { @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 } diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 62715b1..67cc3b8 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import CalendarProvider +import WeatherProvider /// Führt das Einstellungsfenster selbst. /// @@ -19,13 +20,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann. private var selectedTab: SettingsTab = .widgets - func show(model: AppModel, calendarModel: CalendarModel, tab: SettingsTab = .widgets) { + func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, + tab: SettingsTab = .widgets) { selectedTab = tab if window == nil { let hosting = NSHostingController( rootView: SettingsView(model: model, calendarModel: calendarModel, + weatherModel: weatherModel, selectedTab: Binding( get: { [weak self] in self?.selectedTab ?? .widgets }, set: { [weak self] in self?.selectedTab = $0 }))) diff --git a/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings index 33651dc..496b9bd 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings @@ -1,31 +1,95 @@ { - "sourceLanguage" : "en", - "strings" : { - "widget.weather.name" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Wetter" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Weather" } } + "sourceLanguage": "en", + "strings": { + "↑ %@": { + "comment": "A label that shows the high temperature. The argument is the high temperature.", + "isCommentAutoGenerated": true + }, + "↓ %@": { + "comment": "A label that reads \"low\" in the weather app.", + "isCommentAutoGenerated": true + }, + "widget.weather.asOf": { + "comment": "%@ ist die Uhrzeit des Messwerts", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stand %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "As of %@" + } + } } }, - "widget.weather.asOf" : { - "comment" : "%@ ist die Uhrzeit des Messwerts", - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Stand %@" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "As of %@" } } + "widget.weather.locationDenied": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Standortzugriff.\nOrt in den Einstellungen wählen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No location access.\nPick a place in settings." + } + } } }, - "widget.weather.unavailable" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Wetter nicht abrufbar" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Weather unavailable" } } + "widget.weather.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Weather" + } + } } }, - "widget.weather.locationDenied" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Standortzugriff.\nOrt in den Einstellungen wählen." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "No location access.\nPick a place in settings." } } + "widget.weather.unavailable": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter nicht abrufbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Weather unavailable" + } + } + } + }, + "widget.weather.locationUndetermined": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort noch nicht erlaubt.\nIn den Einstellungen freigeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Location not allowed yet.\nGrant it in settings." + } + } } } }, - "version" : "1.0" -} + "version": "1.1" +} \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift index 29b818e..11d7519 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift @@ -28,6 +28,8 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate { authorization == .denied || authorization == .restricted } + var isUndetermined: Bool { authorization == .notDetermined } + func requestAuthorization() { guard authorization == .notDetermined else { return } manager.requestWhenInUseAuthorization() @@ -36,13 +38,7 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate { /// Eine einzelne Ortung. Liefert `nil`, wenn keine Berechtigung vorliegt /// oder nichts hereinkommt. func currentLocation() async -> CLLocation? { - guard !isDenied else { return nil } - if authorization == .notDetermined { - requestAuthorization() - // Auf die Entscheidung des Nutzers warten hieße, hier zu blockieren. - // Beim nächsten Durchlauf steht sie fest. - return nil - } + guard !isDenied, !isUndetermined else { return nil } if let cached = manager.location, cached.timestamp.timeIntervalSinceNow > -900 { return cached } @@ -83,6 +79,13 @@ public final class WeatherKitSource { public init() {} public var locationIsDenied: Bool { location.isDenied } + public var locationIsUndetermined: Bool { location.isUndetermined } + public var locationIsAuthorized: Bool { !location.isDenied && !location.isUndetermined } + + /// Fragt die Ortungsberechtigung an. + /// + /// Muss aus einem Vordergrundfenster gerufen werden — wie beim Kalender + /// zeigt macOS den Dialog sonst nicht an, und die App wirkt kaputt. public func requestLocationAuthorization() { location.requestAuthorization() } public func load(_ place: WeatherPlace) async -> WeatherState { @@ -91,8 +94,14 @@ public final class WeatherKitSource { switch place { case .current: + if location.isUndetermined { + log.notice("Wetter: Ortungsberechtigung noch nicht erteilt") + return .locationUndetermined + } guard let here = await location.currentLocation() else { - return location.isDenied + let denied = location.isDenied + log.error("Wetter: keine Ortung erhalten (verweigert: \(denied, privacy: .public))") + return denied ? .locationDenied : .unavailable(lastKnown: nil, reason: "Standort nicht verfügbar") } diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift index 533f9f7..7cb4d3e 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift @@ -118,8 +118,16 @@ public enum WeatherState: Equatable, Sendable { /// Noch nie geladen. case idle case loading - /// Ortungsberechtigung fehlt — betrifft nur `.current`. + /// Ortungsberechtigung wurde verweigert — betrifft nur `.current`. + /// macOS fragt dann nicht mehr; es hilft nur der Weg über die + /// Systemeinstellungen oder ein fest gewählter Ort. case locationDenied + /// Es wurde noch nie gefragt. Anders als `denied` lohnt hier eine Anfrage — + /// und sie muss aus einem Vordergrundfenster kommen, sonst zeigt macOS + /// keinen Dialog. Ein eigener Zustand, weil "nicht abrufbar" hier eine + /// falsche Auskunft wäre: abrufbar ist es sehr wohl, es fehlt nur die + /// Erlaubnis. + case locationUndetermined /// Kein Netz oder Dienst nicht erreichbar. Der letzte Stand bleibt sichtbar. case unavailable(lastKnown: WeatherSnapshot?, reason: String) } diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift index 2e11d36..efbd363 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -23,7 +23,20 @@ public final class WeatherModel { } public var locationIsDenied: Bool { source.locationIsDenied } - public func requestLocationAuthorization() { source.requestLocationAuthorization() } + public var locationIsUndetermined: Bool { source.locationIsUndetermined } + public var locationIsAuthorized: Bool { source.locationIsAuthorized } + + /// Fragt die Ortungsberechtigung an und lädt danach. + public func requestLocationAuthorization() { + source.requestLocationAuthorization() + // Die Entscheidung fällt asynchron. Kurz warten und dann laden — die + // Alternative wäre ein Delegat quer durch drei Schichten für einen + // Vorgang, der einmal im Leben der App passiert. + Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + self?.load(force: true) + } + } /// Lädt, wenn der Zwischenspeicher abgelaufen ist. /// @@ -99,6 +112,8 @@ private struct WeatherWidgetView: View { } case .locationDenied: Notice(symbol: "location.slash", text: "widget.weather.locationDenied") + case .locationUndetermined: + Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined") case .loading, .idle: ProgressView() .controlSize(.small)