diff --git a/modules/watch-connectivity/index.swift b/modules/watch-connectivity/index.swift index ab25690..a828d06 100644 --- a/modules/watch-connectivity/index.swift +++ b/modules/watch-connectivity/index.swift @@ -1,64 +1,158 @@ import ExpoModulesCore import WatchConnectivity +private class SessionDelegate: NSObject, WCSessionDelegate { + var onActivationComplete: ((WCSessionActivationState) -> Void)? + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error = error { + print("[WatchConnectivity] Activation failed: \(error)") + } else { + print("[WatchConnectivity] Activation complete: state=\(activationState.rawValue)") + } + onActivationComplete?(activationState) + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } +} + public class WatchConnectivityModule: Module { + private var session: WCSession? + private let delegate = SessionDelegate() + private var isActivated = false + private var pendingPromises: [(() -> Void)] = [] + + // WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary + private func cleanForWCSession(_ obj: Any) -> Any { + if let dict = obj as? [String: Any] { + var cleaned: [String: Any] = [:] + for (key, value) in dict { + if value is NSNull { continue } + cleaned[key] = cleanForWCSession(value) + } + return cleaned + } else if let arr = obj as? [Any] { + return arr.filter { !($0 is NSNull) }.map { cleanForWCSession($0) } + } + return obj + } + public func definition() -> ModuleDefinition { Name("WatchConnectivity") + OnCreate { + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self.delegate + self.delegate.onActivationComplete = { [weak self] state in + DispatchQueue.main.async { + self?.isActivated = (state == .activated) + print("[WatchConnectivity] isActivated = \(self?.isActivated ?? false)") + let pending = self?.pendingPromises ?? [] + self?.pendingPromises = [] + pending.forEach { $0() } + } + } + session.activate() + self.session = session + } + AsyncFunction("isSupported") { () -> Bool in return WCSession.isSupported() } AsyncFunction("isPaired") { () -> Bool in - return WCSession.default.isPaired + guard let session = self.session else { return false } + return session.isPaired } AsyncFunction("isWatchAppInstalled") { () -> Bool in - return WCSession.default.isWatchAppInstalled + guard let session = self.session else { return false } + return session.isWatchAppInstalled } AsyncFunction("isReachable") { () -> Bool in - return WCSession.default.isReachable + guard let session = self.session else { return false } + return session.isReachable } AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in - var message = plan - message["type"] = "workoutPlan" + guard let session = self.session else { + print("[WatchConnectivity] sendWorkoutPlan: NO_SESSION") + promise.reject("NO_SESSION", "WCSession not available") + return + } - if WCSession.default.isReachable { - WCSession.default.sendMessage(message, replyHandler: { reply in - promise.resolve(reply) - }, errorHandler: { error in - promise.reject(error) + func doSend() { + var message = plan + message["type"] = "workoutPlan" + + print("[WatchConnectivity] sendWorkoutPlan: isReachable=\(session.isReachable), activationState=\(session.activationState.rawValue)") + + // WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary + // Remove NSNull values (from JS null/undefined) + guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else { + promise.resolve(["status": "sent"]) + return + } + + session.sendMessage(cleaned, replyHandler: nil, errorHandler: { error in + print("[WatchConnectivity] sendMessage failed: \(error), falling back to transferUserInfo") + session.transferUserInfo(cleaned) }) + promise.resolve(["status": "sent"]) + } + + // Check actual session state, not our flag (which has a race condition) + if session.activationState == .activated { + doSend() } else { - WCSession.default.transferUserInfo(message) - promise.resolve(["status": "queued"]) + print("[WatchConnectivity] Session not activated yet (state=\(session.activationState.rawValue)), queuing...") + self.pendingPromises.append(doSend) } } AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in - var message = data - message["type"] = "workoutCompleted" + guard let session = self.session else { + promise.reject("NO_SESSION", "WCSession not available") + return + } - if WCSession.default.isReachable { - WCSession.default.sendMessage(message, replyHandler: { reply in - promise.resolve(reply) - }, errorHandler: { error in - promise.reject(error) - }) + func doSend() { + var message = data + message["type"] = "workoutCompleted" + + if session.isReachable { + session.sendMessage(message, replyHandler: { reply in + promise.resolve(reply) + }, errorHandler: { error in + print("[WatchConnectivity] sendMessage failed, falling back to transferUserInfo: \(error)") + session.transferUserInfo(message) + promise.resolve(["status": "queued"]) + }) + } else { + session.transferUserInfo(message) + promise.resolve(["status": "queued"]) + } + } + + if self.isActivated { + doSend() } else { - WCSession.default.transferUserInfo(message) - promise.resolve(["status": "queued"]) + self.pendingPromises.append(doSend) } } AsyncFunction("getApplicationContext") { () -> [String: Any]? in - return WCSession.default.receivedApplicationContext as [String: Any] + return self.session?.receivedApplicationContext as? [String: Any] } AsyncFunction("updateApplicationContext") { (context: [String: Any]) in - try? WCSession.default.updateApplicationContext(context) + try? self.session?.updateApplicationContext(context) } } } diff --git a/src/features/active-workout/screens/PreSwimScreen.tsx b/src/features/active-workout/screens/PreSwimScreen.tsx index 932e120..bd4cac6 100644 --- a/src/features/active-workout/screens/PreSwimScreen.tsx +++ b/src/features/active-workout/screens/PreSwimScreen.tsx @@ -23,8 +23,9 @@ export function PreSwimScreen() { const navigation = useNavigation>(); const route = useRoute>(); const [workout, setWorkout] = useState(null); + const [syncResult, setSyncResult] = useState<"sent" | "error" | null>(null); const { isSyncing, sendWorkoutToWatch } = useSyncWorkout(); - const { status, isPaired, isInstalled } = useWatchConnection(); + const { status, isPaired, isInstalled, isReady } = useWatchConnection(); useEffect(() => { loadWorkout(); @@ -38,7 +39,7 @@ export function PreSwimScreen() { const handleStartSwim = async () => { if (!workout) return; - if (status !== "connected") { + if (!isReady) { navigation.navigate("PostSwim", { workoutId: route.params.workoutId }); return; } @@ -51,11 +52,11 @@ export function PreSwimScreen() { ); if ("error" in result) { - navigation.navigate("PostSwim", { workoutId: route.params.workoutId }); + setSyncResult("error"); return; } - navigation.navigate("PostSwim", { workoutId: route.params.workoutId }); + setSyncResult("sent"); }; if (!workout) { @@ -66,6 +67,46 @@ export function PreSwimScreen() { ); } + if (syncResult === "sent") { + return ( + + + ⌚ + Workout sent to Watch! + + Open the SwimBuddy app on your Apple Watch to start tracking. + + + navigation.popToTop()} + className="bg-slate-900 rounded-2xl py-4 items-center w-full" + > + Done + + + ); + } + + if (syncResult === "error") { + return ( + + + ⚠️ + Failed to send + + Could not send workout to Watch. Starting without Watch instead. + + + navigation.navigate("PostSwim", { workoutId: route.params.workoutId })} + className="bg-sky-500 rounded-2xl py-4 items-center w-full" + > + Start Without Watch + + + ); + } + return ( @@ -112,14 +153,16 @@ export function PreSwimScreen() { - {status === "connected" - ? "Apple Watch connected" + {isReady + ? "Apple Watch ready" : isPaired - ? "Watch paired but not connected" + ? isInstalled + ? "Watch paired but not in range" + : "Watch paired, app not installed" : "No Watch paired"} @@ -135,14 +178,14 @@ export function PreSwimScreen() { ) : ( - {status === "connected" ? "Send to Watch & Start" : "Start Without Watch"} + {isReady ? "Send to Watch & Start" : "Start Without Watch"} )} - {status === "connected" - ? "Workout will be sent to your Apple Watch" + {isReady + ? "Workout will be synced to your Apple Watch" : "Workout will be tracked on this device"} diff --git a/src/features/watch-connectivity/hooks/useSyncWorkout.ts b/src/features/watch-connectivity/hooks/useSyncWorkout.ts index 13ddaf2..8244e01 100644 --- a/src/features/watch-connectivity/hooks/useSyncWorkout.ts +++ b/src/features/watch-connectivity/hooks/useSyncWorkout.ts @@ -15,6 +15,20 @@ export function useSyncWorkout() { ): Promise => { setIsSyncing(true); try { + console.log("[useSyncWorkout] Checking module availability..."); + + const supported = await WatchConnectivity.isSupported(); + console.log("[useSyncWorkout] isSupported:", supported); + + const paired = await WatchConnectivity.isPaired(); + console.log("[useSyncWorkout] isPaired:", paired); + + const installed = await WatchConnectivity.isWatchAppInstalled(); + console.log("[useSyncWorkout] isWatchAppInstalled:", installed); + + const reachable = await WatchConnectivity.isReachable(); + console.log("[useSyncWorkout] isReachable:", reachable); + const plan = { id: workoutId, name: name ?? "Workout", @@ -29,9 +43,12 @@ export function useSyncWorkout() { })), }; + console.log("[useSyncWorkout] Sending plan:", JSON.stringify(plan).slice(0, 200)); const result = await WatchConnectivity.sendWorkoutPlan(plan); + console.log("[useSyncWorkout] Result:", JSON.stringify(result)); return result as SyncResult; } catch (error: any) { + console.log("[useSyncWorkout] Error:", error.message, error.code); return { error: error.message ?? "Failed to sync" }; } finally { setIsSyncing(false); diff --git a/src/features/watch-connectivity/hooks/useWatchConnection.ts b/src/features/watch-connectivity/hooks/useWatchConnection.ts index 3fe2e6b..70bdd40 100644 --- a/src/features/watch-connectivity/hooks/useWatchConnection.ts +++ b/src/features/watch-connectivity/hooks/useWatchConnection.ts @@ -28,11 +28,13 @@ export function useWatchConnection() { setIsPaired(paired); setIsInstalled(installed); - setStatus(reachable ? "connected" : "disconnected"); + setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected"); } catch { setStatus("disconnected"); } }; - return { status, isPaired, isInstalled }; + const isReady = isPaired && isInstalled; + + return { status, isPaired, isInstalled, isReady }; } diff --git a/targets/watch/Connectivity/PhoneConnector.swift b/targets/watch/Connectivity/PhoneConnector.swift index 760608d..2cb97ac 100644 --- a/targets/watch/Connectivity/PhoneConnector.swift +++ b/targets/watch/Connectivity/PhoneConnector.swift @@ -3,21 +3,20 @@ import WatchConnectivity class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate { static let shared = PhoneConnector() - private var workoutManager: WorkoutManager? + var workoutManager: WorkoutManager? { + didSet { + if let pending = pendingWorkouts { + pending.forEach { workoutManager?.addWorkoutFromPhone($0) } + pendingWorkouts = nil + } + } + } + private var pendingWorkouts: [StoredWorkout]? private override init() { super.init() } - func setup(workoutManager: WorkoutManager) { - self.workoutManager = workoutManager - if WCSession.isSupported() { - let session = WCSession.default - session.delegate = self - session.activate() - } - } - // Send completed workout data to phone static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) { let message: [String: Any] = [ @@ -29,7 +28,8 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate { if WCSession.default.isReachable { WCSession.default.sendMessage(message, replyHandler: nil) { error in - print("Failed to send: \(error)") + print("[PhoneConnector] sendMessage failed, falling back to transferUserInfo: \(error)") + WCSession.default.transferUserInfo(message) } } else { WCSession.default.transferUserInfo(message) @@ -39,32 +39,66 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate { // MARK: - WCSessionDelegate func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - print("WatchConnectivity activated: \(activationState.rawValue)") + print("[PhoneConnector] Activated: state=\(activationState.rawValue), isReachable=\(session.isReachable)") + } + + func sessionReachabilityDidChange(_ session: WCSession) { + print("[PhoneConnector] Reachability changed: isReachable=\(session.isReachable)") } func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + print("[PhoneConnector] didReceiveMessage (no reply)") DispatchQueue.main.async { [weak self] in self?.handleMessage(message) } } - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + print("[PhoneConnector] didReceiveMessage (with reply)") + replyHandler(["status": "received"]) + DispatchQueue.main.async { [weak self] in + self?.handleMessage(message) + } + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + print("[PhoneConnector] didReceiveUserInfo") DispatchQueue.main.async { [weak self] in self?.handleMessage(userInfo) } } private func handleMessage(_ message: [String: Any]) { - guard let type = message["type"] as? String else { return } + guard let type = message["type"] as? String else { + print("[PhoneConnector] No type in message: \(message.keys)") + return + } + + print("[PhoneConnector] Received message type: \(type)") switch type { case "workoutPlan": - if let data = try? JSONSerialization.data(withJSONObject: message), - let workout = try? JSONDecoder().decode(StoredWorkout.self, from: data) { - workoutManager?.addWorkoutFromPhone(workout) + guard let data = try? JSONSerialization.data(withJSONObject: message) else { + print("[PhoneConnector] Failed to serialize message") + return + } + print("[PhoneConnector] JSON data: \(String(data: data, encoding: .utf8) ?? "nil")") + + do { + let workout = try JSONDecoder().decode(StoredWorkout.self, from: data) + print("[PhoneConnector] Decoded workout: \(workout.name ?? "nil"), sets: \(workout.sets.count)") + if let manager = workoutManager { + manager.addWorkoutFromPhone(workout) + } else { + print("[PhoneConnector] workoutManager not set, queuing workout") + if pendingWorkouts == nil { pendingWorkouts = [] } + pendingWorkouts?.append(workout) + } + } catch { + print("[PhoneConnector] Decode error: \(error)") } default: - break + print("[PhoneConnector] Unknown type: \(type)") } } } diff --git a/targets/watch/ContentView.swift b/targets/watch/ContentView.swift index 6398247..d4979bc 100644 --- a/targets/watch/ContentView.swift +++ b/targets/watch/ContentView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ContentView: View { - @StateObject private var workoutManager = WorkoutManager() + @EnvironmentObject var workoutManager: WorkoutManager var body: some View { NavigationView { diff --git a/targets/watch/WorkoutManager.swift b/targets/watch/WorkoutManager.swift index fd68606..9d52dc7 100644 --- a/targets/watch/WorkoutManager.swift +++ b/targets/watch/WorkoutManager.swift @@ -25,8 +25,6 @@ class WorkoutManager: ObservableObject { private var lapStartTime: Date? private var completedLaps: [CompletedLap] = [] - private let suiteName = "group.com.swimbuddy.app" - init() { healthStore = HKHealthStore() } @@ -34,7 +32,7 @@ class WorkoutManager: ObservableObject { // MARK: - Data Persistence func loadSavedWorkouts() { - guard let defaults = UserDefaults(suiteName: suiteName) else { return } + let defaults = UserDefaults.standard if let data = defaults.data(forKey: "savedWorkouts"), let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) { @@ -48,7 +46,7 @@ class WorkoutManager: ObservableObject { } func saveWorkoutsToStorage() { - guard let defaults = UserDefaults(suiteName: suiteName) else { return } + let defaults = UserDefaults.standard if let data = try? JSONEncoder().encode(savedWorkouts) { defaults.set(data, forKey: "savedWorkouts") } @@ -234,16 +232,14 @@ class WorkoutManager: ObservableObject { "duration": elapsedSeconds, ] - if let defaults = UserDefaults(suiteName: suiteName) { - var completed = completedWorkouts - var summary = selectedWorkout - summary?.completedAt = ISO8601DateFormatter().string(from: Date()) - if let s = summary { - completed.insert(s, at: 0) - } - if let data = try? JSONEncoder().encode(completed) { - defaults.set(data, forKey: "completedWorkouts") - } + var completed = completedWorkouts + var summary = selectedWorkout + summary?.completedAt = ISO8601DateFormatter().string(from: Date()) + if let s = summary { + completed.insert(s, at: 0) + } + if let data = try? JSONEncoder().encode(completed) { + UserDefaults.standard.set(data, forKey: "completedWorkouts") } if WCSession.default.isReachable { diff --git a/targets/watch/index.swift b/targets/watch/index.swift index 5e05600..7c425ab 100644 --- a/targets/watch/index.swift +++ b/targets/watch/index.swift @@ -1,15 +1,24 @@ import SwiftUI +import WatchConnectivity @main struct SwimTrackerWatchApp: App { @StateObject private var workoutManager = WorkoutManager() + init() { + if WCSession.isSupported() { + let session = WCSession.default + session.delegate = PhoneConnector.shared + session.activate() + } + } + var body: some Scene { WindowGroup { ContentView() .environmentObject(workoutManager) .onAppear { - PhoneConnector.shared.setup(workoutManager: workoutManager) + PhoneConnector.shared.workoutManager = workoutManager } } }