fix(watch-connectivity): improve WCSession lifecycle and message reliability
This commit is contained in:
@@ -1,64 +1,158 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
import WatchConnectivity
|
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 {
|
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 {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("WatchConnectivity")
|
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
|
AsyncFunction("isSupported") { () -> Bool in
|
||||||
return WCSession.isSupported()
|
return WCSession.isSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("isPaired") { () -> Bool in
|
AsyncFunction("isPaired") { () -> Bool in
|
||||||
return WCSession.default.isPaired
|
guard let session = self.session else { return false }
|
||||||
|
return session.isPaired
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("isWatchAppInstalled") { () -> Bool in
|
AsyncFunction("isWatchAppInstalled") { () -> Bool in
|
||||||
return WCSession.default.isWatchAppInstalled
|
guard let session = self.session else { return false }
|
||||||
|
return session.isWatchAppInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("isReachable") { () -> Bool in
|
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
|
AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in
|
||||||
|
guard let session = self.session else {
|
||||||
|
print("[WatchConnectivity] sendWorkoutPlan: NO_SESSION")
|
||||||
|
promise.reject("NO_SESSION", "WCSession not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func doSend() {
|
||||||
var message = plan
|
var message = plan
|
||||||
message["type"] = "workoutPlan"
|
message["type"] = "workoutPlan"
|
||||||
|
|
||||||
if WCSession.default.isReachable {
|
print("[WatchConnectivity] sendWorkoutPlan: isReachable=\(session.isReachable), activationState=\(session.activationState.rawValue)")
|
||||||
WCSession.default.sendMessage(message, replyHandler: { reply in
|
|
||||||
promise.resolve(reply)
|
// WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary
|
||||||
}, errorHandler: { error in
|
// Remove NSNull values (from JS null/undefined)
|
||||||
promise.reject(error)
|
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 {
|
} else {
|
||||||
WCSession.default.transferUserInfo(message)
|
print("[WatchConnectivity] Session not activated yet (state=\(session.activationState.rawValue)), queuing...")
|
||||||
promise.resolve(["status": "queued"])
|
self.pendingPromises.append(doSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
|
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
|
||||||
|
guard let session = self.session else {
|
||||||
|
promise.reject("NO_SESSION", "WCSession not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func doSend() {
|
||||||
var message = data
|
var message = data
|
||||||
message["type"] = "workoutCompleted"
|
message["type"] = "workoutCompleted"
|
||||||
|
|
||||||
if WCSession.default.isReachable {
|
if session.isReachable {
|
||||||
WCSession.default.sendMessage(message, replyHandler: { reply in
|
session.sendMessage(message, replyHandler: { reply in
|
||||||
promise.resolve(reply)
|
promise.resolve(reply)
|
||||||
}, errorHandler: { error in
|
}, errorHandler: { error in
|
||||||
promise.reject(error)
|
print("[WatchConnectivity] sendMessage failed, falling back to transferUserInfo: \(error)")
|
||||||
|
session.transferUserInfo(message)
|
||||||
|
promise.resolve(["status": "queued"])
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
WCSession.default.transferUserInfo(message)
|
session.transferUserInfo(message)
|
||||||
promise.resolve(["status": "queued"])
|
promise.resolve(["status": "queued"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.isActivated {
|
||||||
|
doSend()
|
||||||
|
} else {
|
||||||
|
self.pendingPromises.append(doSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("getApplicationContext") { () -> [String: Any]? in
|
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
|
AsyncFunction("updateApplicationContext") { (context: [String: Any]) in
|
||||||
try? WCSession.default.updateApplicationContext(context)
|
try? self.session?.updateApplicationContext(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export function PreSwimScreen() {
|
|||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
|
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
|
||||||
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
|
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
|
||||||
|
const [syncResult, setSyncResult] = useState<"sent" | "error" | null>(null);
|
||||||
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
|
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
|
||||||
const { status, isPaired, isInstalled } = useWatchConnection();
|
const { status, isPaired, isInstalled, isReady } = useWatchConnection();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWorkout();
|
loadWorkout();
|
||||||
@@ -38,7 +39,7 @@ export function PreSwimScreen() {
|
|||||||
const handleStartSwim = async () => {
|
const handleStartSwim = async () => {
|
||||||
if (!workout) return;
|
if (!workout) return;
|
||||||
|
|
||||||
if (status !== "connected") {
|
if (!isReady) {
|
||||||
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,11 +52,11 @@ export function PreSwimScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
setSyncResult("error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
setSyncResult("sent");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!workout) {
|
if (!workout) {
|
||||||
@@ -66,6 +67,46 @@ export function PreSwimScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (syncResult === "sent") {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white p-4 items-center justify-center">
|
||||||
|
<View className="bg-green-50 rounded-2xl p-6 items-center mb-6 w-full">
|
||||||
|
<Text className="text-4xl mb-3">⌚</Text>
|
||||||
|
<Text className="text-xl font-bold text-green-800 mb-2">Workout sent to Watch!</Text>
|
||||||
|
<Text className="text-green-600 text-center">
|
||||||
|
Open the SwimBuddy app on your Apple Watch to start tracking.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.popToTop()}
|
||||||
|
className="bg-slate-900 rounded-2xl py-4 items-center w-full"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncResult === "error") {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white p-4 items-center justify-center">
|
||||||
|
<View className="bg-red-50 rounded-2xl p-6 items-center mb-6 w-full">
|
||||||
|
<Text className="text-4xl mb-3">⚠️</Text>
|
||||||
|
<Text className="text-xl font-bold text-red-800 mb-2">Failed to send</Text>
|
||||||
|
<Text className="text-red-600 text-center">
|
||||||
|
Could not send workout to Watch. Starting without Watch instead.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate("PostSwim", { workoutId: route.params.workoutId })}
|
||||||
|
className="bg-sky-500 rounded-2xl py-4 items-center w-full"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">Start Without Watch</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white p-4">
|
<View className="flex-1 bg-white p-4">
|
||||||
<View className="items-center mb-6">
|
<View className="items-center mb-6">
|
||||||
@@ -112,14 +153,16 @@ export function PreSwimScreen() {
|
|||||||
<View className="bg-slate-50 rounded-2xl p-3 mb-6 flex-row items-center gap-3">
|
<View className="bg-slate-50 rounded-2xl p-3 mb-6 flex-row items-center gap-3">
|
||||||
<View
|
<View
|
||||||
className={`w-3 h-3 rounded-full ${
|
className={`w-3 h-3 rounded-full ${
|
||||||
status === "connected" ? "bg-green-500" : "bg-slate-300"
|
isReady ? "bg-green-500" : isPaired ? "bg-yellow-500" : "bg-slate-300"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<Text className="text-sm text-slate-600">
|
<Text className="text-sm text-slate-600">
|
||||||
{status === "connected"
|
{isReady
|
||||||
? "Apple Watch connected"
|
? "Apple Watch ready"
|
||||||
: isPaired
|
: isPaired
|
||||||
? "Watch paired but not connected"
|
? isInstalled
|
||||||
|
? "Watch paired but not in range"
|
||||||
|
: "Watch paired, app not installed"
|
||||||
: "No Watch paired"}
|
: "No Watch paired"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -135,14 +178,14 @@ export function PreSwimScreen() {
|
|||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
<Text className="text-white font-bold text-lg">
|
<Text className="text-white font-bold text-lg">
|
||||||
{status === "connected" ? "Send to Watch & Start" : "Start Without Watch"}
|
{isReady ? "Send to Watch & Start" : "Start Without Watch"}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Text className="text-slate-400 text-xs text-center mt-3">
|
<Text className="text-slate-400 text-xs text-center mt-3">
|
||||||
{status === "connected"
|
{isReady
|
||||||
? "Workout will be sent to your Apple Watch"
|
? "Workout will be synced to your Apple Watch"
|
||||||
: "Workout will be tracked on this device"}
|
: "Workout will be tracked on this device"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export function useSyncWorkout() {
|
|||||||
): Promise<SyncResult> => {
|
): Promise<SyncResult> => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
try {
|
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 = {
|
const plan = {
|
||||||
id: workoutId,
|
id: workoutId,
|
||||||
name: name ?? "Workout",
|
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);
|
const result = await WatchConnectivity.sendWorkoutPlan(plan);
|
||||||
|
console.log("[useSyncWorkout] Result:", JSON.stringify(result));
|
||||||
return result as SyncResult;
|
return result as SyncResult;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.log("[useSyncWorkout] Error:", error.message, error.code);
|
||||||
return { error: error.message ?? "Failed to sync" };
|
return { error: error.message ?? "Failed to sync" };
|
||||||
} finally {
|
} finally {
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ export function useWatchConnection() {
|
|||||||
|
|
||||||
setIsPaired(paired);
|
setIsPaired(paired);
|
||||||
setIsInstalled(installed);
|
setIsInstalled(installed);
|
||||||
setStatus(reachable ? "connected" : "disconnected");
|
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { status, isPaired, isInstalled };
|
const isReady = isPaired && isInstalled;
|
||||||
|
|
||||||
|
return { status, isPaired, isInstalled, isReady };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,20 @@ import WatchConnectivity
|
|||||||
|
|
||||||
class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
|
class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
|
||||||
static let shared = PhoneConnector()
|
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() {
|
private override init() {
|
||||||
super.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
|
// Send completed workout data to phone
|
||||||
static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) {
|
static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) {
|
||||||
let message: [String: Any] = [
|
let message: [String: Any] = [
|
||||||
@@ -29,7 +28,8 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
|
|||||||
|
|
||||||
if WCSession.default.isReachable {
|
if WCSession.default.isReachable {
|
||||||
WCSession.default.sendMessage(message, replyHandler: nil) { error in
|
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 {
|
} else {
|
||||||
WCSession.default.transferUserInfo(message)
|
WCSession.default.transferUserInfo(message)
|
||||||
@@ -39,32 +39,66 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
|
|||||||
// MARK: - WCSessionDelegate
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
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]) {
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
print("[PhoneConnector] didReceiveMessage (no reply)")
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.handleMessage(message)
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.handleMessage(userInfo)
|
self?.handleMessage(userInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleMessage(_ message: [String: Any]) {
|
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 {
|
switch type {
|
||||||
case "workoutPlan":
|
case "workoutPlan":
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: message),
|
guard let data = try? JSONSerialization.data(withJSONObject: message) else {
|
||||||
let workout = try? JSONDecoder().decode(StoredWorkout.self, from: data) {
|
print("[PhoneConnector] Failed to serialize message")
|
||||||
workoutManager?.addWorkoutFromPhone(workout)
|
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:
|
default:
|
||||||
break
|
print("[PhoneConnector] Unknown type: \(type)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var workoutManager = WorkoutManager()
|
@EnvironmentObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ class WorkoutManager: ObservableObject {
|
|||||||
private var lapStartTime: Date?
|
private var lapStartTime: Date?
|
||||||
private var completedLaps: [CompletedLap] = []
|
private var completedLaps: [CompletedLap] = []
|
||||||
|
|
||||||
private let suiteName = "group.com.swimbuddy.app"
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
healthStore = HKHealthStore()
|
healthStore = HKHealthStore()
|
||||||
}
|
}
|
||||||
@@ -34,7 +32,7 @@ class WorkoutManager: ObservableObject {
|
|||||||
// MARK: - Data Persistence
|
// MARK: - Data Persistence
|
||||||
|
|
||||||
func loadSavedWorkouts() {
|
func loadSavedWorkouts() {
|
||||||
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
if let data = defaults.data(forKey: "savedWorkouts"),
|
if let data = defaults.data(forKey: "savedWorkouts"),
|
||||||
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
|
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
|
||||||
@@ -48,7 +46,7 @@ class WorkoutManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveWorkoutsToStorage() {
|
func saveWorkoutsToStorage() {
|
||||||
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
let defaults = UserDefaults.standard
|
||||||
if let data = try? JSONEncoder().encode(savedWorkouts) {
|
if let data = try? JSONEncoder().encode(savedWorkouts) {
|
||||||
defaults.set(data, forKey: "savedWorkouts")
|
defaults.set(data, forKey: "savedWorkouts")
|
||||||
}
|
}
|
||||||
@@ -234,7 +232,6 @@ class WorkoutManager: ObservableObject {
|
|||||||
"duration": elapsedSeconds,
|
"duration": elapsedSeconds,
|
||||||
]
|
]
|
||||||
|
|
||||||
if let defaults = UserDefaults(suiteName: suiteName) {
|
|
||||||
var completed = completedWorkouts
|
var completed = completedWorkouts
|
||||||
var summary = selectedWorkout
|
var summary = selectedWorkout
|
||||||
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
|
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
|
||||||
@@ -242,8 +239,7 @@ class WorkoutManager: ObservableObject {
|
|||||||
completed.insert(s, at: 0)
|
completed.insert(s, at: 0)
|
||||||
}
|
}
|
||||||
if let data = try? JSONEncoder().encode(completed) {
|
if let data = try? JSONEncoder().encode(completed) {
|
||||||
defaults.set(data, forKey: "completedWorkouts")
|
UserDefaults.standard.set(data, forKey: "completedWorkouts")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if WCSession.default.isReachable {
|
if WCSession.default.isReachable {
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct SwimTrackerWatchApp: App {
|
struct SwimTrackerWatchApp: App {
|
||||||
@StateObject private var workoutManager = WorkoutManager()
|
@StateObject private var workoutManager = WorkoutManager()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = PhoneConnector.shared
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(workoutManager)
|
.environmentObject(workoutManager)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
PhoneConnector.shared.setup(workoutManager: workoutManager)
|
PhoneConnector.shared.workoutManager = workoutManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user