Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem gemeinsamen Repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
82 lines
3.1 KiB
Swift
82 lines
3.1 KiB
Swift
import Foundation
|
|
import WatchConnectivity
|
|
import WidgetKit
|
|
@_spi(Writer) import CalendarrCore
|
|
|
|
/// Takes what the phone sends and writes it into this watch's own App Group
|
|
/// container, using the same `SnapshotStore` the phone writes with. One wire
|
|
/// format, one reader, no drift.
|
|
///
|
|
/// Only the app runs a `WCSession`; a widget extension cannot. The complications
|
|
/// always read what this wrote.
|
|
@MainActor
|
|
final class WatchSnapshotReceiver: NSObject {
|
|
static let shared = WatchSnapshotReceiver()
|
|
|
|
private let store = SnapshotStore()
|
|
private let sessions = SharedSessionStore()
|
|
|
|
/// Shown in the agenda footer, because a silent failure here looks exactly
|
|
/// like an empty calendar.
|
|
private(set) var lastError: String?
|
|
|
|
func activate() {
|
|
guard WCSession.isSupported() else { return }
|
|
WCSession.default.delegate = self
|
|
WCSession.default.activate()
|
|
}
|
|
|
|
/// Ask the phone for the current state. Covers "the app is open in front of
|
|
/// me and looks stale"; the other two paths are push-driven.
|
|
func requestRefresh() {
|
|
let session = WCSession.default
|
|
guard session.activationState == .activated, session.isReachable else { return }
|
|
|
|
session.sendMessage(["request": "snapshot"],
|
|
replyHandler: { reply in
|
|
Task { @MainActor in self.apply(reply) }
|
|
},
|
|
errorHandler: { error in
|
|
Task { @MainActor in self.lastError = error.localizedDescription }
|
|
})
|
|
}
|
|
|
|
func apply(_ dict: [String: Any]) {
|
|
guard !dict.isEmpty else { return }
|
|
do {
|
|
let payload = try WatchTransportPayload.decode(dict)
|
|
// This write posts SnapshotChangeNotifier, which is what the read
|
|
// model listens for — so the UI updates without being told twice.
|
|
try store.write(payload.snapshot)
|
|
try store.writeCalendars(payload.calendars)
|
|
if let session = payload.session { try sessions.write(session) }
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
lastError = nil
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WatchSnapshotReceiver: WCSessionDelegate {
|
|
nonisolated func session(_ session: WCSession,
|
|
activationDidCompleteWith state: WCSessionActivationState,
|
|
error: Error?) {
|
|
if let error {
|
|
Task { @MainActor in self.lastError = error.localizedDescription }
|
|
}
|
|
}
|
|
|
|
/// The main path: latest state only, delivered when this app launches or wakes.
|
|
nonisolated func session(_ session: WCSession,
|
|
didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
Task { @MainActor in self.apply(applicationContext) }
|
|
}
|
|
|
|
/// The complication push, which is the only path that wakes this app while
|
|
/// it is not running.
|
|
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
|
Task { @MainActor in self.apply(userInfo) }
|
|
}
|
|
}
|