diff --git a/App.tsx b/App.tsx index 3558171..5b467ed 100644 --- a/App.tsx +++ b/App.tsx @@ -4,12 +4,19 @@ import { NavigationContainer } from "@react-navigation/native"; import { RootNavigator } from "./src/navigation/RootNavigator"; import { DatabaseProvider } from "./src/providers/DatabaseProvider"; import { SettingsProvider } from "./src/providers/WatchProvider"; +import { useWatchWorkoutSync } from "./src/features/watch-connectivity/hooks/useWatchWorkoutSync"; + +function WatchSync() { + useWatchWorkoutSync(); + return null; +} export default function App() { return ( + diff --git a/modules/watch-connectivity/index.swift b/modules/watch-connectivity/index.swift index a828d06..c7b51f1 100644 --- a/modules/watch-connectivity/index.swift +++ b/modules/watch-connectivity/index.swift @@ -3,6 +3,8 @@ import WatchConnectivity private class SessionDelegate: NSObject, WCSessionDelegate { var onActivationComplete: ((WCSessionActivationState) -> Void)? + var onMessageReceived: (([String: Any]) -> Void)? + var onUserInfoReceived: (([String: Any]) -> Void)? func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { @@ -13,6 +15,22 @@ private class SessionDelegate: NSObject, WCSessionDelegate { onActivationComplete?(activationState) } + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + print("[WatchConnectivity] Received message: \(message["type"] ?? "unknown")") + onMessageReceived?(message) + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + print("[WatchConnectivity] Received message with reply: \(message["type"] ?? "unknown")") + replyHandler(["status": "received"]) + onMessageReceived?(message) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + print("[WatchConnectivity] Received userInfo: \(userInfo["type"] ?? "unknown")") + onUserInfoReceived?(userInfo) + } + func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { @@ -25,6 +43,7 @@ public class WatchConnectivityModule: Module { private let delegate = SessionDelegate() private var isActivated = false private var pendingPromises: [(() -> Void)] = [] + private var pendingEvents: [[String: Any]] = [] // WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary private func cleanForWCSession(_ obj: Any) -> Any { @@ -57,6 +76,12 @@ public class WatchConnectivityModule: Module { pending.forEach { $0() } } } + self.delegate.onMessageReceived = { [weak self] message in + self?.handleIncomingMessage(message) + } + self.delegate.onUserInfoReceived = { [weak self] userInfo in + self?.handleIncomingMessage(userInfo) + } session.activate() self.session = session } @@ -93,8 +118,6 @@ public class WatchConnectivityModule: Module { 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 @@ -107,7 +130,6 @@ public class WatchConnectivityModule: Module { promise.resolve(["status": "sent"]) } - // Check actual session state, not our flag (which has a race condition) if session.activationState == .activated { doSend() } else { @@ -126,18 +148,20 @@ public class WatchConnectivityModule: Module { var message = data message["type"] = "workoutCompleted" + guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else { + promise.resolve(["status": "sent"]) + return + } + 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"]) + session.sendMessage(cleaned, replyHandler: nil, errorHandler: { error in + print("[WatchConnectivity] sendMessage failed: \(error), falling back to transferUserInfo") + session.transferUserInfo(cleaned) }) } else { - session.transferUserInfo(message) - promise.resolve(["status": "queued"]) + session.transferUserInfo(cleaned) } + promise.resolve(["status": "sent"]) } if self.isActivated { @@ -154,5 +178,20 @@ public class WatchConnectivityModule: Module { AsyncFunction("updateApplicationContext") { (context: [String: Any]) in try? self.session?.updateApplicationContext(context) } + + AsyncFunction("pollEvents") { () -> [[String: Any]] in + let events = self.pendingEvents + self.pendingEvents = [] + return events + } + } + + private func handleIncomingMessage(_ message: [String: Any]) { + guard let type = message["type"] as? String else { return } + print("[WatchConnectivity] handleIncomingMessage type=\(type)") + + DispatchQueue.main.async { + self.pendingEvents.append(message) + } } } diff --git a/src/db/operations.ts b/src/db/operations.ts index 7753483..fb26f91 100644 --- a/src/db/operations.ts +++ b/src/db/operations.ts @@ -135,6 +135,51 @@ export async function getWorkoutLaps(workoutId: string) { return db.select().from(laps).where(eq(laps.workoutId, workoutId)); } +export async function saveWatchCompletedWorkout(data: { + workoutId: string; + duration: number; + totalDistance: number; + calories: number; + laps: Array<{ + setPosition: number; + repNumber: number; + distance: number; + duration: number; + heartRate: number; + }>; +}) { + const avgHeartRate = + data.laps.length > 0 + ? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length) + : 0; + + await db + .update(workouts) + .set({ + finishedAt: new Date().toISOString(), + totalDuration: data.duration, + totalDistance: data.totalDistance || 0, + calories: data.calories || 0, + avgHeartRate, + source: "watch", + }) + .where(eq(workouts.id, data.workoutId)); + + for (const lap of data.laps) { + await db.insert(laps).values({ + id: generateUUID(), + workoutId: data.workoutId, + setPosition: lap.setPosition, + repNumber: lap.repNumber, + distance: lap.distance, + duration: lap.duration, + heartRate: lap.heartRate || null, + strokeCount: null, + swolf: null, + }); + } +} + // ─── Stats ─────────────────────────────────────────────────── export async function getWeeklyStats() { diff --git a/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts b/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts new file mode 100644 index 0000000..d06643e --- /dev/null +++ b/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef } from "react"; +import WatchConnectivity from "../../../modules/watch-connectivity"; +import { saveWatchCompletedWorkout } from "@db/operations"; + +export function useWatchWorkoutSync() { + const pollingRef = useRef | null>(null); + + useEffect(() => { + pollingRef.current = setInterval(async () => { + try { + const events = (await WatchConnectivity.pollEvents()) as any[]; + for (const event of events) { + if (event.type === "workoutCompleted") { + console.log("[useWatchWorkoutSync] Received workoutCompleted:", event.workoutId); + await saveWatchCompletedWorkout({ + workoutId: event.workoutId, + duration: event.duration ?? 0, + totalDistance: event.totalDistance ?? 0, + calories: event.calories ?? 0, + laps: event.laps ?? [], + }); + console.log("[useWatchWorkoutSync] Saved completed workout to DB"); + } + } + } catch (error) { + // Silently ignore polling errors + } + }, 2000); + + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); +} diff --git a/targets/watch/ContentView.swift b/targets/watch/ContentView.swift index d4979bc..48243c0 100644 --- a/targets/watch/ContentView.swift +++ b/targets/watch/ContentView.swift @@ -8,11 +8,13 @@ struct ContentView: View { VStack(spacing: 16) { if workoutManager.isWorkoutActive { ActiveWorkoutView(workoutManager: workoutManager) + } else if workoutManager.selectedWorkout != nil { + WorkoutDetailView(workoutManager: workoutManager) } else { WorkoutListView(workoutManager: workoutManager) } } - .navigationTitle("SwimBuddy") + .navigationTitle(workoutManager.isWorkoutActive ? "" : "SwimBuddy") } } } @@ -61,6 +63,9 @@ struct WorkoutListView: View { } } } + .onDelete { indexSet in + workoutManager.deleteSavedWorkouts(at: indexSet) + } } if workoutManager.completedWorkouts.count > 0 { @@ -81,6 +86,9 @@ struct WorkoutListView: View { .font(.caption) } } + .onDelete { indexSet in + workoutManager.deleteCompletedWorkouts(at: indexSet) + } } } } diff --git a/targets/watch/Views/ActiveWorkoutView.swift b/targets/watch/Views/ActiveWorkoutView.swift index c90fe25..60e3b68 100644 --- a/targets/watch/Views/ActiveWorkoutView.swift +++ b/targets/watch/Views/ActiveWorkoutView.swift @@ -24,44 +24,83 @@ struct SwimView: View { workoutManager.currentSetIndex < workout.sets.count { let currentSet = workout.sets[workoutManager.currentSetIndex] - Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)") - .font(.caption2) - .foregroundColor(.secondary) + // Set/Rep info + HStack { + Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)") + .font(.caption2) + .foregroundColor(.secondary) + Text("·") + .foregroundColor(.secondary) + Text("Rep \(workoutManager.currentRep + 1)/\(currentSet.repCount)") + .font(.caption2) + .foregroundColor(.secondary) + } + // Stroke name Text(currentSet.stroke) .font(.headline) .foregroundColor(.cyan) - Text("Rep \(workoutManager.currentRep + 1)/\(currentSet.repCount)") - .font(.subheadline) - - Text("\(currentSet.distancePerRep)m") + // Current set distance + Text("\(Int(workoutManager.currentSetDistanceSwum))/\(Int(workoutManager.currentSetTotalDistance))m") .font(.system(size: 36, weight: .bold, design: .rounded)) .foregroundColor(.cyan) } - Text(formatTime(workoutManager.elapsedSeconds)) - .font(.system(.title, design: .monospaced)) + // Total distance + HStack(spacing: 4) { + Image(systemName: "figure.pool.swim") + .font(.caption) + .foregroundColor(.cyan) + Text("\(Int(workoutManager.totalDistanceSwum))m total") + .font(.caption) + .foregroundColor(.secondary) + } - if workoutManager.heartRate > 0 { + // Elapsed time + Text(formatTime(workoutManager.elapsedSeconds)) + .font(.system(.title3, design: .monospaced)) + + // Calories + Heart rate + HStack(spacing: 12) { HStack(spacing: 4) { - Image(systemName: "heart.fill") - .foregroundColor(.red) + Image(systemName: "flame.fill") + .foregroundColor(.orange) .font(.caption) - Text("\(Int(workoutManager.heartRate))") + Text("\(Int(workoutManager.estimatedCalories))") .font(.caption) } + + if workoutManager.heartRate > 0 { + HStack(spacing: 4) { + Image(systemName: "heart.fill") + .foregroundColor(.red) + .font(.caption) + Text("\(Int(workoutManager.heartRate))") + .font(.caption) + } + } } - Button { - workoutManager.completeLap() - } label: { - Text("Complete Lap") - .font(.headline) - .frame(maxWidth: .infinity) + // Lap detection indicator + if workoutManager.lapDetected { + Text("Lap detected!") + .font(.caption) + .foregroundColor(.green) + .transition(.opacity) + } + + HStack(spacing: 12) { + Button { + workoutManager.completeLap() + } label: { + Text("Complete Lap") + .font(.headline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.cyan) } - .buttonStyle(.borderedProminent) - .tint(.cyan) Button("End") { workoutManager.endWorkout() @@ -98,6 +137,25 @@ struct RestView: View { .font(.caption) .foregroundColor(.secondary) } + + // Show stats during rest too + HStack(spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "figure.pool.swim") + .font(.caption) + .foregroundColor(.cyan) + Text("\(Int(workoutManager.totalDistanceSwum))m") + .font(.caption) + } + HStack(spacing: 4) { + Image(systemName: "flame.fill") + .font(.caption) + .foregroundColor(.orange) + Text("\(Int(workoutManager.estimatedCalories))kcal") + .font(.caption) + } + } + .foregroundColor(.secondary) } } diff --git a/targets/watch/Views/WorkoutDetailView.swift b/targets/watch/Views/WorkoutDetailView.swift new file mode 100644 index 0000000..d76d5dd --- /dev/null +++ b/targets/watch/Views/WorkoutDetailView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct WorkoutDetailView: View { + @ObservedObject var workoutManager: WorkoutManager + + var body: some View { + List { + if let workout = workoutManager.selectedWorkout { + Section { + VStack(spacing: 8) { + Text(workout.name ?? "Workout") + .font(.headline) + + Text("\(Int(workout.poolLength))m pool") + .font(.caption) + .foregroundColor(.secondary) + + Text("\(workout.sets.reduce(0) { $0 + $1.distancePerRep * $1.repCount })m total") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + + Section("Sets") { + ForEach(Array(workout.sets.enumerated()), id: \.offset) { i, set in + HStack { + Text("\(i + 1). \(set.stroke)") + .font(.body) + Spacer() + Text("\(set.repCount)x\(set.distancePerRep)m") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Section { + Button { + workoutManager.startWorkout() + } label: { + HStack { + Spacer() + Image(systemName: "play.fill") + Text("Start") + .font(.headline) + Spacer() + } + } + .buttonStyle(.borderedProminent) + .tint(.cyan) + + Button("Back") { + workoutManager.selectedWorkout = nil + } + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + } + } + .listStyle(.automatic) + } +} diff --git a/targets/watch/WorkoutManager.swift b/targets/watch/WorkoutManager.swift index 9d52dc7..92db94e 100644 --- a/targets/watch/WorkoutManager.swift +++ b/targets/watch/WorkoutManager.swift @@ -3,6 +3,7 @@ import SwiftUI import HealthKit import WatchKit import WatchConnectivity +import CoreMotion class WorkoutManager: ObservableObject { @Published var isWorkoutActive = false @@ -25,6 +26,14 @@ class WorkoutManager: ObservableObject { private var lapStartTime: Date? private var completedLaps: [CompletedLap] = [] + // Lap detection + private let motionManager = CMMotionManager() + private var lapDetectionTimer: Timer? + private var lastLapDetectionTime: Date = .distantPast + private var wasStationary = false + private var stationarySince: Date? + @Published var lapDetected = false + init() { healthStore = HKHealthStore() } @@ -60,6 +69,22 @@ class WorkoutManager: ObservableObject { saveWorkoutsToStorage() } + func deleteSavedWorkouts(at offsets: IndexSet) { + savedWorkouts.remove(atOffsets: offsets) + saveWorkoutsToStorage() + } + + func deleteCompletedWorkouts(at offsets: IndexSet) { + let limited = Array(completedWorkouts.prefix(3)) + for i in offsets { + if i < limited.count { + let id = limited[i].id + completedWorkouts.removeAll { $0.id == id } + } + } + saveWorkoutsToStorage() + } + // MARK: - Workout Selection func selectWorkout(_ workout: StoredWorkout) { @@ -76,19 +101,25 @@ class WorkoutManager: ObservableObject { currentRep = 0 elapsedSeconds = 0 completedLaps = [] + wasStationary = false + stationarySince = nil startHealthKitWorkout() startTimer() + startLapDetection() + WKInterfaceDevice.current().enableWaterLock() WKInterfaceDevice.current().play(.start) } func pauseWorkout() { workoutTimer?.invalidate() + stopLapDetection() WKInterfaceDevice.current().play(.notification) } func resumeWorkout() { startTimer() + startLapDetection() WKInterfaceDevice.current().play(.start) } @@ -97,6 +128,7 @@ class WorkoutManager: ObservableObject { restTimer?.invalidate() isWorkoutActive = false + stopLapDetection() endHealthKitWorkout() syncCompletedWorkout() WKInterfaceDevice.current().play(.success) @@ -178,6 +210,90 @@ class WorkoutManager: ObservableObject { } } + // MARK: - Computed Stats + + var totalDistanceSwum: Double { + var distance: Double = 0 + for lap in completedLaps { + distance += Double(lap.distance) + } + return distance + } + + var currentSetDistanceSwum: Double { + guard let workout = selectedWorkout, currentSetIndex < workout.sets.count else { return 0 } + let setDistance = workout.sets[currentSetIndex].distancePerRep + let completedInSet = completedLaps.filter { $0.setPosition == currentSetIndex + 1 }.count + return Double(setDistance) * Double(completedInSet) + } + + var currentSetTotalDistance: Double { + guard let workout = selectedWorkout, currentSetIndex < workout.sets.count else { return 0 } + let set = workout.sets[currentSetIndex] + return Double(set.distancePerRep) * Double(set.repCount) + } + + var estimatedCalories: Double { + return totalDistanceSwum * 0.07 + } + + // MARK: - Lap Detection (Accelerometer) + + private func startLapDetection() { + guard motionManager.isAccelerometerAvailable else { return } + motionManager.accelerometerUpdateInterval = 0.1 // 10 Hz + motionManager.startAccelerometerUpdates() + + lapDetectionTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in + self?.processAccelerometerData() + } + } + + private func stopLapDetection() { + lapDetectionTimer?.invalidate() + lapDetectionTimer = nil + motionManager.stopAccelerometerUpdates() + } + + private func processAccelerometerData() { + guard let data = motionManager.accelerometerData else { return } + + let magnitude = sqrt(data.acceleration.x * data.acceleration.x + + data.acceleration.y * data.acceleration.y + + data.acceleration.z * data.acceleration.z) + + let isStationary = magnitude < 1.1 // Near rest (~1g from gravity) + let now = Date() + + if isStationary { + if !wasStationary { + wasStationary = true + stationarySince = now + } + } else { + if wasStationary, let since = stationarySince { + let pauseDuration = now.timeIntervalSince(since) + let timeSinceLastLap = now.timeIntervalSince(lastLapDetectionTime) + + // Wall touch: was stationary for 0.3-3s, then pushed off + // Min 8s between laps to avoid false positives + if pauseDuration >= 0.3 && pauseDuration <= 3.0 && timeSinceLastLap >= 8.0 { + DispatchQueue.main.async { + self.completeLap() + self.lapDetected = true + self.lastLapDetectionTime = now + } + // Reset after detection + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.lapDetected = false + } + } + } + wasStationary = false + stationarySince = nil + } + } + // MARK: - Timer private func startTimer() { @@ -220,6 +336,8 @@ class WorkoutManager: ObservableObject { let workoutSummary: [String: Any] = [ "type": "workoutCompleted", "workoutId": selectedWorkout?.id ?? "", + "name": selectedWorkout?.name ?? "Workout", + "poolLength": selectedWorkout?.poolLength ?? 0, "laps": completedLaps.map { lap in [ "setPosition": lap.setPosition, @@ -230,6 +348,8 @@ class WorkoutManager: ObservableObject { ] as [String: Any] }, "duration": elapsedSeconds, + "totalDistance": Int(totalDistanceSwum), + "calories": Int(estimatedCalories), ] var completed = completedWorkouts @@ -242,13 +362,9 @@ class WorkoutManager: ObservableObject { UserDefaults.standard.set(data, forKey: "completedWorkouts") } - if WCSession.default.isReachable { - WCSession.default.sendMessage(workoutSummary, replyHandler: nil) { error in - print("Failed to sync to phone: \(error)") - } - } else { - WCSession.default.transferUserInfo(workoutSummary) - } + // Always use transferUserInfo for reliable delivery + WCSession.default.transferUserInfo(workoutSummary) + print("[WorkoutManager] Synced completed workout to phone") } }