Widget-Optionen statt „Quellen", Reiterleiste von der Trennlinie herunter
Die Leiste saß mittig auf der Trennlinie unter dem Titelbalken. `titlebarAppearsTransparent` allein reichte nicht — macOS zieht darunter zusätzlich einen Trenner quer durchs Fenster. Der ist jetzt aus. Man setzt keinen Titel auf eine Trennlinie. „Quellen" war die Sicht des Programmierers: für den Nutzer ist die Datenquelle eine Einstellung **des Kalenderwidgets** unter mehreren. Der Bereich heißt jetzt „Widget-Optionen" und hat einen Abschnitt je Widget. Kalender: woher die Daten kommen, Monatsraster oder Terminliste — und endlich, welche Kalender überhaupt mitzählen. Gespeichert werden die **abgewählten**, nicht die gewählten: kommt ein neuer Kalender dazu, ist er damit automatisch dabei, statt dass man ihn suchen müsste, ohne zu wissen, dass es ihn gibt. Der Filter lässt die Abdeckung unberührt — welche Kalender man sehen will, sagt nichts darüber aus, für welchen Zeitraum Daten vorliegen. Wetter: dem Standort folgen oder einen festen Ort setzen, per Stadt oder Postleitzahl. WeatherKit will Koordinaten, der Nutzer kennt Ortsnamen — die Umsetzung passiert einmal beim Einstellen statt bei jedem Abruf. Der Ort überlebt jetzt auch den Neustart; vorher stand nach jedem Start wieder der Aufenthaltsort da. Die Ablage-Einstellungen sind vom Widget-Reiter hierher gewandert, wo die anderen Widget-Optionen stehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
||||||
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
||||||
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
|
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
|
||||||
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
|
||||||
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||||
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
|
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = "<group>"; };
|
085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = "<group>"; };
|
||||||
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = "<group>"; };
|
||||||
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
|
||||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
|
||||||
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
|
||||||
@@ -177,6 +179,7 @@
|
|||||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
||||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
||||||
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
||||||
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
||||||
);
|
);
|
||||||
path = Onyx;
|
path = Onyx;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -354,6 +357,7 @@
|
|||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
||||||
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,8 +32,9 @@ struct SettingsView: View {
|
|||||||
DisplaySettings(model: model)
|
DisplaySettings(model: model)
|
||||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||||||
.tag(SettingsTab.display)
|
.tag(SettingsTab.display)
|
||||||
CalendarSourceSettings(model: calendarModel)
|
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
||||||
.tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") }
|
shelfStore: shelfStore)
|
||||||
|
.tabItem { Label("settings.tab.options", systemImage: "slider.horizontal.3") }
|
||||||
.tag(SettingsTab.sources)
|
.tag(SettingsTab.sources)
|
||||||
MenuBarSettings(model: model, metricsModel: metricsModel)
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||||
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
||||||
@@ -410,11 +411,6 @@ private struct WidgetSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let shelfStore {
|
|
||||||
Section("settings.shelf.title") {
|
|
||||||
ShelfSettingsRows(store: shelfStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
|
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: hosting)
|
||||||
window.title = "Onyx"
|
window.title = "Onyx"
|
||||||
// Der Titelbalken geht in den Inhalt über, statt ihn mit einer
|
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
|
||||||
// Linie abzuschneiden — sonst liegt die Reiterleiste direkt auf
|
// verschwindet.
|
||||||
// dieser Linie.
|
//
|
||||||
|
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
|
||||||
|
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
|
||||||
|
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
|
||||||
|
// Titel auf eine Trennlinie.
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.titlebarSeparatorStyle = .none
|
||||||
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
|
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
|
||||||
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
|
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
|
||||||
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
|
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
|
||||||
|
|||||||
230
Onyx/WidgetOptionsView.swift
Normal file
230
Onyx/WidgetOptionsView.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CalendarProvider
|
||||||
|
import WeatherProvider
|
||||||
|
import ShelfProvider
|
||||||
|
|
||||||
|
/// Die Stellschrauben der einzelnen Widgets, an einem Ort.
|
||||||
|
///
|
||||||
|
/// Vorher hieß der Bereich „Quellen" und enthielt nur die Kalenderquelle. Das
|
||||||
|
/// war die Sicht des Programmierers: für den Nutzer ist die Quelle eine
|
||||||
|
/// Einstellung **des Kalenderwidgets** unter mehreren — welche Kalender
|
||||||
|
/// mitzählen, gehört genauso dazu, und beim Wetter der Ort. Ein Abschnitt je
|
||||||
|
/// Widget ist die Ordnung, die man sucht.
|
||||||
|
struct WidgetOptionsView: View {
|
||||||
|
let calendarModel: CalendarModel
|
||||||
|
let weatherModel: WeatherModel
|
||||||
|
let shelfStore: ShelfStore?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("options.calendar") {
|
||||||
|
CalendarOptions(model: calendarModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("options.weather") {
|
||||||
|
WeatherOptions(model: weatherModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let shelfStore {
|
||||||
|
Section("options.shelf") {
|
||||||
|
ShelfOptions(store: shelfStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kalender
|
||||||
|
|
||||||
|
private struct CalendarOptions: View {
|
||||||
|
@Bindable var model: CalendarModel
|
||||||
|
@State private var calendarrState: CalendarSourceState?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(selection: $model.kind) {
|
||||||
|
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||||||
|
Text(kind.localizedName).tag(kind)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("options.calendar.source")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Picker(selection: $model.showsMonthGrid) {
|
||||||
|
Text("options.calendar.agenda").tag(false)
|
||||||
|
Text("options.calendar.month").tag(true)
|
||||||
|
} label: {
|
||||||
|
Text("options.calendar.display")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**:
|
||||||
|
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn
|
||||||
|
// suchen muss, ohne zu wissen, dass es ihn gibt.
|
||||||
|
if model.availableCalendars.isEmpty {
|
||||||
|
Text("options.calendar.noneFound")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(model.availableCalendars) { calendar in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { !model.hiddenCalendarIDs.contains(calendar.id) },
|
||||||
|
set: { shown in
|
||||||
|
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
|
||||||
|
else { model.hiddenCalendarIDs.insert(calendar.id) }
|
||||||
|
})) {
|
||||||
|
Label {
|
||||||
|
Text(calendar.title)
|
||||||
|
} icon: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: calendar.colorHex) ?? .secondary)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.kind != .appleCalendar {
|
||||||
|
CalendarrStatus(state: calendarrState)
|
||||||
|
.task(id: model.kind) {
|
||||||
|
guard model.kind != .appleCalendar else { return }
|
||||||
|
let now = Date()
|
||||||
|
calendarrState = await CalendarrSource().load(
|
||||||
|
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
||||||
|
end: now.addingTimeInterval(60 * 86_400)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
|
||||||
|
private struct CalendarrStatus: View {
|
||||||
|
let state: CalendarSourceState?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch state {
|
||||||
|
case .ready(let window):
|
||||||
|
LabeledContent {
|
||||||
|
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} label: {
|
||||||
|
Text("options.calendar.calendarrEvents \(window.events.count)")
|
||||||
|
}
|
||||||
|
case .neverWritten:
|
||||||
|
Text("calendar.source.status.neverWritten").font(.callout).foregroundStyle(.secondary)
|
||||||
|
case .loggedOut:
|
||||||
|
Text("calendar.source.status.loggedOut").font(.callout).foregroundStyle(.secondary)
|
||||||
|
case .incompatible:
|
||||||
|
Text("calendar.source.status.incompatible").font(.callout).foregroundStyle(.orange)
|
||||||
|
case .unreadable(let reason):
|
||||||
|
Text(reason).font(.callout).foregroundStyle(.orange)
|
||||||
|
case .denied, .none:
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wetter
|
||||||
|
|
||||||
|
private struct WeatherOptions: View {
|
||||||
|
let model: WeatherModel
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var searching = false
|
||||||
|
@State private var notFound = false
|
||||||
|
|
||||||
|
private var usesCurrentLocation: Bool { model.place.isCurrent }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { usesCurrentLocation },
|
||||||
|
set: { model.place = $0 ? .current : model.place })) {
|
||||||
|
Text("options.weather.automatic")
|
||||||
|
}
|
||||||
|
.disabled(usesCurrentLocation)
|
||||||
|
|
||||||
|
if usesCurrentLocation {
|
||||||
|
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
|
||||||
|
// steht hier, statt dass das Widget stumm leer bleibt.
|
||||||
|
if model.locationIsDenied {
|
||||||
|
Text("options.weather.needsLocation")
|
||||||
|
.font(.callout).foregroundStyle(.orange)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} else if case .fixed(let name, _, _) = model.place {
|
||||||
|
LabeledContent {
|
||||||
|
Text(name).foregroundStyle(.secondary)
|
||||||
|
} label: {
|
||||||
|
Text("options.weather.place")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField("options.weather.search", text: $query)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit { Task { await search() } }
|
||||||
|
if searching {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("options.weather.set") { Task { await search() } }
|
||||||
|
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notFound {
|
||||||
|
Text("options.weather.notFound").font(.callout).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func search() async {
|
||||||
|
searching = true
|
||||||
|
notFound = false
|
||||||
|
defer { searching = false }
|
||||||
|
if let found = await model.selectPlace(named: query) {
|
||||||
|
query = ""
|
||||||
|
_ = found
|
||||||
|
} else {
|
||||||
|
notFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ablage
|
||||||
|
|
||||||
|
private struct ShelfOptions: 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 extension Color {
|
||||||
|
/// Die Farbe eines Kalenders, wie die Quelle sie liefert.
|
||||||
|
init?(hex: String?) {
|
||||||
|
guard let hex, hex.hasPrefix("#"), hex.count == 7,
|
||||||
|
let value = Int(hex.dropFirst(), radix: 16) else { return nil }
|
||||||
|
self.init(red: Double((value >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((value >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(value & 0xFF) / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,3 +67,18 @@ public extension EventWindow {
|
|||||||
return EventWindow(events: events, coverage: coverage, asOf: asOf)
|
return EventWindow(events: events, coverage: coverage, asOf: asOf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension CalendarSourceState {
|
||||||
|
/// Blendet die abgewählten Kalender aus.
|
||||||
|
///
|
||||||
|
/// Die Abdeckung bleibt unangetastet: welche Kalender man sehen will, sagt
|
||||||
|
/// nichts darüber aus, für welchen Zeitraum Daten vorliegen. Ein Tag ohne
|
||||||
|
/// sichtbare Termine bleibt ein bekannter Tag.
|
||||||
|
func filteringHidden(_ hidden: Set<String>) -> CalendarSourceState {
|
||||||
|
guard case .ready(let window) = self, !hidden.isEmpty else { return self }
|
||||||
|
return .ready(EventWindow(
|
||||||
|
events: window.events.filter { !hidden.contains($0.calendarID) },
|
||||||
|
coverage: window.coverage,
|
||||||
|
asOf: window.asOf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,24 @@ public final class CalendarModel {
|
|||||||
didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) }
|
didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welche Kalender Daten liefern. `nil` heißt: alle.
|
||||||
|
///
|
||||||
|
/// Bewusst eine Liste der **abgewählten**, nicht der gewählten: kommt ein
|
||||||
|
/// neuer Kalender dazu, ist er dann automatisch dabei. Andersherum müsste
|
||||||
|
/// man ihn suchen und anhaken, ohne zu wissen, dass es ihn gibt.
|
||||||
|
public var hiddenCalendarIDs: Set<String> {
|
||||||
|
didSet {
|
||||||
|
guard hiddenCalendarIDs != oldValue else { return }
|
||||||
|
defaults.set(Array(hiddenCalendarIDs), forKey: Self.hiddenKey)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kalender, die zur Auswahl stehen.
|
||||||
|
public private(set) var availableCalendars: [OnyxCalendar] = []
|
||||||
|
|
||||||
private static let kindKey = "calendar.source"
|
private static let kindKey = "calendar.source"
|
||||||
|
private static let hiddenKey = "calendar.hiddenCalendars"
|
||||||
private static let gridKey = "calendar.showsMonthGrid"
|
private static let gridKey = "calendar.showsMonthGrid"
|
||||||
|
|
||||||
private let appleSource: CalendarSource
|
private let appleSource: CalendarSource
|
||||||
@@ -51,6 +68,7 @@ public final class CalendarModel {
|
|||||||
self.kind = defaults.string(forKey: Self.kindKey)
|
self.kind = defaults.string(forKey: Self.kindKey)
|
||||||
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
|
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
|
||||||
self.showsMonthGrid = defaults.bool(forKey: Self.gridKey)
|
self.showsMonthGrid = defaults.bool(forKey: Self.gridKey)
|
||||||
|
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
|
||||||
observeCalendarrIfNeeded()
|
observeCalendarrIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +157,16 @@ public final class CalendarModel {
|
|||||||
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
||||||
}
|
}
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
state = CalendarSourceKind.merge(states)
|
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
|
||||||
|
|
||||||
|
// Die Liste der wählbaren Kalender nebenbei mitführen — sie ändert
|
||||||
|
// sich, wenn jemand in Kalender einen anlegt oder die Quelle
|
||||||
|
// wechselt.
|
||||||
|
var found: [OnyxCalendar] = []
|
||||||
|
for source in activeSources where source.isAuthorized {
|
||||||
|
found.append(contentsOf: await source.calendars())
|
||||||
|
}
|
||||||
|
availableCalendars = found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,166 +1,167 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage" : "en",
|
||||||
"strings": {
|
"strings" : {
|
||||||
"airdrop.hint": {
|
"airdrop.hint" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Zum Senden ablegen"
|
"value" : "Zum Senden ablegen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Drop to send"
|
"value" : "Drop to send"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"airdrop.name": {
|
"airdrop.name" : {
|
||||||
"localizations": {
|
"extractionState" : "stale",
|
||||||
"de": {
|
"localizations" : {
|
||||||
"stringUnit": {
|
"de" : {
|
||||||
"state": "translated",
|
"stringUnit" : {
|
||||||
"value": "AirDrop"
|
"state" : "translated",
|
||||||
|
"value" : "AirDrop"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "AirDrop"
|
"value" : "AirDrop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"airdrop.unavailable": {
|
"airdrop.unavailable" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "AirDrop kann das nicht senden"
|
"value" : "AirDrop kann das nicht senden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "AirDrop cannot send this"
|
"value" : "AirDrop cannot send this"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.airdrop": {
|
"shelf.airdrop" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Per AirDrop senden"
|
"value" : "Per AirDrop senden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Send via AirDrop"
|
"value" : "Send via AirDrop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.clear": {
|
"shelf.clear" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Alles leeren"
|
"value" : "Alles leeren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Clear all"
|
"value" : "Clear all"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.delete": {
|
"shelf.delete" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "In den Papierkorb"
|
"value" : "In den Papierkorb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Move to Trash"
|
"value" : "Move to Trash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.empty": {
|
"shelf.empty" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Dateien hierhin ziehen, um sie zu parken."
|
"value" : "Dateien hierhin ziehen, um sie zu parken."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Drop files here to park them."
|
"value" : "Drop files here to park them."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.name": {
|
"shelf.name" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Ablage & AirDrop"
|
"value" : "Ablage & AirDrop"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Shelf & AirDrop"
|
"value" : "Shelf & AirDrop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.open": {
|
"shelf.open" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Öffnen"
|
"value" : "Öffnen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Open"
|
"value" : "Open"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shelf.reveal": {
|
"shelf.reveal" : {
|
||||||
"localizations": {
|
"localizations" : {
|
||||||
"de": {
|
"de" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Im Finder zeigen"
|
"value" : "Im Finder zeigen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en" : {
|
||||||
"stringUnit": {
|
"stringUnit" : {
|
||||||
"state": "translated",
|
"state" : "translated",
|
||||||
"value": "Show in Finder"
|
"value" : "Show in Finder"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.1"
|
"version" : "1.1"
|
||||||
}
|
}
|
||||||
@@ -174,6 +174,22 @@ public final class WeatherKitSource {
|
|||||||
return fetched
|
return fetched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Koordinaten zu einem Ortsnamen.
|
||||||
|
///
|
||||||
|
/// Mit Zeitlimit wie alles andere hier: eine Ortssuche ohne Netz wartet
|
||||||
|
/// sonst, bis jemand das Fenster schließt.
|
||||||
|
public func coordinates(for query: String) async -> (name: String,
|
||||||
|
coordinate: CLLocationCoordinate2D)? {
|
||||||
|
let found = await withTimeout(seconds: 8) {
|
||||||
|
try? await CLGeocoder().geocodeAddressString(query).first
|
||||||
|
} ?? nil
|
||||||
|
guard let placemark = found, let location = placemark.location else { return nil }
|
||||||
|
let name = [placemark.locality, placemark.administrativeArea, placemark.country]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.first ?? query
|
||||||
|
return (name, location.coordinate)
|
||||||
|
}
|
||||||
|
|
||||||
private func name(for location: CLLocation) async -> String {
|
private func name(for location: CLLocation) async -> String {
|
||||||
// Auch hier ein Zeitlimit: ohne Netz wartet die Umkehrsuche lange.
|
// Auch hier ein Zeitlimit: ohne Netz wartet die Umkehrsuche lange.
|
||||||
// `CLGeocoder` ist nicht `Sendable`, also wird es im Aufruf angelegt
|
// `CLGeocoder` ist nicht `Sendable`, also wird es im Aufruf angelegt
|
||||||
|
|||||||
@@ -19,16 +19,30 @@ public final class WeatherModel {
|
|||||||
/// liefert nur den Wert zum Zeitpunkt des Lesens.
|
/// liefert nur den Wert zum Zeitpunkt des Lesens.
|
||||||
public private(set) var locationAuthorization: LocationAuthorization = .undetermined
|
public private(set) var locationAuthorization: LocationAuthorization = .undetermined
|
||||||
public var place: WeatherPlace {
|
public var place: WeatherPlace {
|
||||||
didSet { guard place != oldValue else { return }; load(force: true) }
|
didSet {
|
||||||
|
guard place != oldValue else { return }
|
||||||
|
if let data = try? JSONEncoder().encode(place) {
|
||||||
|
defaults.set(data, forKey: Self.placeKey)
|
||||||
|
}
|
||||||
|
load(force: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let placeKey = "weather.place"
|
||||||
|
|
||||||
private let source: WeatherKitSource
|
private let source: WeatherKitSource
|
||||||
|
private let defaults: UserDefaults
|
||||||
private var cache = WeatherCache()
|
private var cache = WeatherCache()
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) {
|
public init(source: WeatherKitSource = WeatherKitSource(),
|
||||||
|
defaults: UserDefaults = .standard) {
|
||||||
self.source = source
|
self.source = source
|
||||||
self.place = place
|
self.defaults = defaults
|
||||||
|
// Der gewählte Ort überlebt den Neustart — sonst steht nach jedem
|
||||||
|
// Start wieder das Wetter des Aufenthaltsorts da.
|
||||||
|
self.place = defaults.data(forKey: Self.placeKey)
|
||||||
|
.flatMap { try? JSONDecoder().decode(WeatherPlace.self, from: $0) } ?? .current
|
||||||
// Sofort zeigen, was zuletzt bekannt war. Ein Wert von vor einer Stunde
|
// Sofort zeigen, was zuletzt bekannt war. Ein Wert von vor einer Stunde
|
||||||
// mit Zeitstempel ist eine Auskunft; ein Rädchen ist keine.
|
// mit Zeitstempel ist eine Auskunft; ein Rädchen ist keine.
|
||||||
if let known = cache.lastKnown(for: place) {
|
if let known = cache.lastKnown(for: place) {
|
||||||
@@ -103,6 +117,23 @@ public final class WeatherModel {
|
|||||||
task?.cancel()
|
task?.cancel()
|
||||||
task = nil
|
task = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sucht die Koordinaten zu einem Ortsnamen und übernimmt ihn.
|
||||||
|
///
|
||||||
|
/// WeatherKit will Koordinaten, der Nutzer kennt Ortsnamen. Die Umsetzung
|
||||||
|
/// dazwischen passiert einmal beim Einstellen, nicht bei jedem Abruf.
|
||||||
|
///
|
||||||
|
/// - Returns: der gefundene Ort, oder `nil` wenn es keinen gab.
|
||||||
|
@discardableResult
|
||||||
|
public func selectPlace(named query: String) async -> String? {
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
guard let found = await source.coordinates(for: trimmed) else { return nil }
|
||||||
|
place = .fixed(name: found.name,
|
||||||
|
latitude: found.coordinate.latitude,
|
||||||
|
longitude: found.coordinate.longitude)
|
||||||
|
return found.name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Widget
|
// MARK: - Widget
|
||||||
|
|||||||
Reference in New Issue
Block a user