- watchOS: add WatchSessionManager (WCSession bridge), WatchNotificationHandler (snooze/dismiss actions), recommendations() for AppIntentTimelineProvider - watchOS: WatchTimerStore tick loop with cascade haptics, App Group sync - macOS: CreateTimerSheet (countdown/alarm/pomodoro), launch-at-login toggle - macOS: MenuBarState showCreateSheet, MacSettingsView data tab - Xcode project updated with new file references
167 lines
5.6 KiB
Swift
167 lines
5.6 KiB
Swift
// WCSession manager for bidirectional iPhone ↔ Watch sync
|
|
|
|
import Foundation
|
|
import WatchConnectivity
|
|
import os
|
|
|
|
// MARK: - Watch Timer Command
|
|
|
|
enum WatchTimerCommand: Codable {
|
|
case snooze(id: String, minutes: Int)
|
|
case dismiss(id: String)
|
|
case pause(id: String)
|
|
case resume(id: String)
|
|
case complete(id: String)
|
|
|
|
var messageDict: [String: Any] {
|
|
switch self {
|
|
case .snooze(let id, let minutes):
|
|
return ["command": "snooze", "id": id, "minutes": minutes]
|
|
case .dismiss(let id):
|
|
return ["command": "dismiss", "id": id]
|
|
case .pause(let id):
|
|
return ["command": "pause", "id": id]
|
|
case .resume(let id):
|
|
return ["command": "resume", "id": id]
|
|
case .complete(let id):
|
|
return ["command": "complete", "id": id]
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Watch Session Manager
|
|
|
|
@MainActor
|
|
final class WatchSessionManager: NSObject, ObservableObject {
|
|
static let shared = WatchSessionManager()
|
|
|
|
@Published var isReachable = false
|
|
|
|
private let session: WCSession
|
|
private let logger = Logger(subsystem: "com.chronomind.watch", category: "WatchSessionManager")
|
|
private var pendingCommands: [[String: Any]] = []
|
|
|
|
private override init() {
|
|
session = WCSession.default
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Activation
|
|
|
|
func activate() {
|
|
guard WCSession.isSupported() else {
|
|
logger.warning("WCSession not supported on this device")
|
|
return
|
|
}
|
|
session.delegate = self
|
|
session.activate()
|
|
logger.info("WCSession activation requested")
|
|
}
|
|
|
|
// MARK: - Send Command
|
|
|
|
func sendCommand(_ command: WatchTimerCommand) {
|
|
let message = command.messageDict
|
|
guard session.activationState == .activated else {
|
|
logger.warning("Session not activated, queuing command")
|
|
pendingCommands.append(message)
|
|
return
|
|
}
|
|
|
|
if session.isReachable {
|
|
session.sendMessage(message, replyHandler: { reply in
|
|
Task { @MainActor in
|
|
self.logger.info("Command acknowledged: \(reply.description)")
|
|
}
|
|
}, errorHandler: { error in
|
|
Task { @MainActor in
|
|
self.logger.error("Failed to send command: \(error.localizedDescription)")
|
|
self.pendingCommands.append(message)
|
|
}
|
|
})
|
|
} else {
|
|
// Fallback: use application context for non-urgent delivery
|
|
do {
|
|
try session.updateApplicationContext(["pendingCommand": message])
|
|
logger.info("Command sent via application context")
|
|
} catch {
|
|
logger.error("Failed to update application context: \(error.localizedDescription)")
|
|
pendingCommands.append(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Flush Pending
|
|
|
|
private func flushPendingCommands() {
|
|
guard session.isReachable, !pendingCommands.isEmpty else { return }
|
|
let commands = pendingCommands
|
|
pendingCommands.removeAll()
|
|
|
|
for command in commands {
|
|
session.sendMessage(command, replyHandler: nil) { [weak self] error in
|
|
Task { @MainActor in
|
|
self?.logger.error("Failed to flush command: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
logger.info("Flushed \(commands.count) pending commands")
|
|
}
|
|
}
|
|
|
|
// MARK: - WCSessionDelegate
|
|
|
|
extension WatchSessionManager: WCSessionDelegate {
|
|
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
Task { @MainActor in
|
|
if let error = error {
|
|
logger.error("WCSession activation failed: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
logger.info("WCSession activated: \(activationState.rawValue)")
|
|
isReachable = session.isReachable
|
|
flushPendingCommands()
|
|
}
|
|
}
|
|
|
|
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
|
Task { @MainActor in
|
|
isReachable = session.isReachable
|
|
logger.info("Reachability changed: \(session.isReachable)")
|
|
if session.isReachable {
|
|
flushPendingCommands()
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
Task { @MainActor in
|
|
logger.info("Received application context update")
|
|
// iPhone pushed updated timer data — refresh from App Group
|
|
NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil)
|
|
}
|
|
}
|
|
|
|
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
Task { @MainActor in
|
|
logger.info("Received message: \(message.keys.joined(separator: ", "))")
|
|
// iPhone pushed a real-time update — refresh from App Group
|
|
NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil)
|
|
}
|
|
}
|
|
|
|
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
|
Task { @MainActor in
|
|
logger.info("Received message with reply: \(message.keys.joined(separator: ", "))")
|
|
NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil)
|
|
replyHandler(["status": "received"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Name
|
|
|
|
extension Notification.Name {
|
|
static let watchTimerDataUpdated = Notification.Name("watchTimerDataUpdated")
|
|
}
|