Address findings from comprehensive audit across 5 workstreams: - Memory: Token-based DataController listeners (prevent closure leaks), static DateFormatters, ImageCache observer cleanup, MotionManager reference counting, FoundationModels dedup guard - Concurrency: Replace Task.detached with Task in FeelsApp (preserve MainActor isolation), wrap WatchConnectivity handler in MainActor - Performance: Cache sortedGroupedData in DayViewViewModel, cache demo data in MonthView/YearView, remove broken ReduceMotionModifier - Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell labels, MonthCard button labels, InsightsView header traits, Smart Invert protection on neon headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
6.8 KiB
Swift
205 lines
6.8 KiB
Swift
//
|
|
// WatchConnectivityManager.swift
|
|
// Feels
|
|
//
|
|
// 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.tt.feels", 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
|
|
}
|