The watch receives the phone's payload over WatchConnectivity and writes it into its own App Group container through the same SnapshotStore, so there is one wire format and one reader. The agenda, the detail screen and the filter all read that; the complications read what the app last wrote, since an extension cannot run a WCSession of its own. Complication timelines land on event boundaries rather than a schedule, so the next appointment flips in the exact minute it changes without spending refresh budget. CalendarrWatchShared holds what both watch targets need, attached to each the way Shared/ is attached to the app and the widgets. recommendations() is implemented because watchOS requires it — iOS has a default, the watch does not, since the complication gallery has to be populated. Found by building, not by reading. Co-Authored-By: Claude Opus 5 <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) }
|
|
}
|
|
}
|