Files
Reflect/Shared/Services/WatchConnectivityManager.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

205 lines
6.8 KiB
Swift

//
// WatchConnectivityManager.swift
// Reflect
//
// Central coordinator for Watch Connectivity.
// Used for immediate UI updates (Live Activity, widget refresh).
// Data persistence is handled by CloudKit sync.
//
import Foundation
import WatchConnectivity
import WidgetKit
import os.log
/// Manages Watch Connectivity between iOS and watchOS
/// Used for immediate notifications, not data persistence (CloudKit handles that)
final class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
private static let logger = Logger(subsystem: "com.88oakapps.reflect", category: "WatchConnectivity")
private var session: WCSession?
#if os(watchOS)
/// Pending moods to send when session activates
private var pendingMoods: [(mood: Int, date: Date)] = []
#endif
/// Whether the paired device is currently reachable for immediate messaging
var isReachable: Bool {
session?.isReachable ?? false
}
private override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
Self.logger.info("WCSession activation requested")
} else {
Self.logger.warning("WCSession not supported on this device")
}
}
// MARK: - iOS Watch
#if os(iOS)
/// Notify watch to reload its complications
func notifyWatchToReload() {
guard let session = session,
session.activationState == .activated,
session.isWatchAppInstalled else {
return
}
let message = ["action": "reloadWidgets"]
session.transferUserInfo(message)
Self.logger.info("Sent reload notification to watch")
}
#endif
// MARK: - Watch iOS
#if os(watchOS)
/// Send mood to iOS app for immediate side effects (Live Activity, etc.)
/// Data persistence is handled by CloudKit - this is just for immediate UI updates
func sendMoodToPhone(mood: Int, date: Date) -> Bool {
guard let session = session else {
Self.logger.warning("WCSession not ready - session is nil")
return false
}
guard session.activationState == .activated else {
pendingMoods.append((mood: mood, date: date))
if session.activationState == .notActivated {
session.activate()
}
Self.logger.warning("WCSession not activated, queued mood for later")
return false
}
let message: [String: Any] = [
"action": "logMood",
"mood": mood,
"date": date.timeIntervalSince1970
]
// Try immediate message first if iPhone is reachable
if session.isReachable {
session.sendMessage(message, replyHandler: nil) { error in
Self.logger.warning("sendMessage failed: \(error.localizedDescription), using transferUserInfo")
session.transferUserInfo(message)
}
Self.logger.info("Sent mood \(mood) to iPhone via sendMessage")
} else {
session.transferUserInfo(message)
Self.logger.info("Sent mood \(mood) to iPhone via transferUserInfo")
}
return true
}
#endif
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
Self.logger.error("WCSession activation failed: \(error.localizedDescription)")
} else {
Self.logger.info("WCSession activation completed: \(activationState.rawValue)")
#if os(watchOS)
if activationState == .activated && !pendingMoods.isEmpty {
Self.logger.info("Sending \(self.pendingMoods.count) pending mood(s)")
for pending in pendingMoods {
_ = sendMoodToPhone(mood: pending.mood, date: pending.date)
}
pendingMoods.removeAll()
}
#endif
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
Self.logger.info("WCSession became inactive")
}
func sessionDidDeactivate(_ session: WCSession) {
Self.logger.info("WCSession deactivated, reactivating...")
session.activate()
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
Self.logger.info("Received userInfo from watch")
handleReceivedMessage(userInfo)
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Self.logger.info("Received message from watch")
handleReceivedMessage(message)
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
Self.logger.info("Received message from watch (with reply)")
handleReceivedMessage(message)
replyHandler(["status": "received"])
}
private func handleReceivedMessage(_ message: [String: Any]) {
Task { @MainActor in
guard let action = message["action"] as? String else {
Self.logger.error("No action in message")
return
}
switch action {
case "logMood":
guard let moodRaw = message["mood"] as? Int,
let mood = Mood(rawValue: moodRaw),
let timestamp = message["date"] as? TimeInterval else {
Self.logger.error("Invalid mood message format")
return
}
let date = Date(timeIntervalSince1970: timestamp)
Self.logger.info("Processing mood \(moodRaw) from watch for \(date)")
MoodLogger.shared.logMood(mood, for: date, entryType: .watch)
case "reloadWidgets":
Self.logger.info("Received reloadWidgets from watch")
WidgetCenter.shared.reloadAllTimelines()
default:
Self.logger.warning("Unknown action: \(action)")
}
}
}
#endif
#if os(watchOS)
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if userInfo["action"] as? String == "reloadWidgets" {
Self.logger.info("Received reload notification from iPhone")
Task { @MainActor in
WidgetCenter.shared.reloadAllTimelines()
}
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
if message["action"] as? String == "reloadWidgets" {
Task { @MainActor in
WidgetCenter.shared.reloadAllTimelines()
}
}
}
#endif
}