feat(watch): add lap detection via accelerometer and workout sync to phone

This commit is contained in:
2026-08-07 16:35:20 +02:00
parent 00a8220096
commit 48286041c6
8 changed files with 414 additions and 40 deletions
+7
View File
@@ -4,12 +4,19 @@ import { NavigationContainer } from "@react-navigation/native";
import { RootNavigator } from "./src/navigation/RootNavigator"; import { RootNavigator } from "./src/navigation/RootNavigator";
import { DatabaseProvider } from "./src/providers/DatabaseProvider"; import { DatabaseProvider } from "./src/providers/DatabaseProvider";
import { SettingsProvider } from "./src/providers/WatchProvider"; import { SettingsProvider } from "./src/providers/WatchProvider";
import { useWatchWorkoutSync } from "./src/features/watch-connectivity/hooks/useWatchWorkoutSync";
function WatchSync() {
useWatchWorkoutSync();
return null;
}
export default function App() { export default function App() {
return ( return (
<DatabaseProvider> <DatabaseProvider>
<SettingsProvider> <SettingsProvider>
<NavigationContainer> <NavigationContainer>
<WatchSync />
<RootNavigator /> <RootNavigator />
<StatusBar style="light" /> <StatusBar style="light" />
</NavigationContainer> </NavigationContainer>
+50 -11
View File
@@ -3,6 +3,8 @@ import WatchConnectivity
private class SessionDelegate: NSObject, WCSessionDelegate { private class SessionDelegate: NSObject, WCSessionDelegate {
var onActivationComplete: ((WCSessionActivationState) -> Void)? var onActivationComplete: ((WCSessionActivationState) -> Void)?
var onMessageReceived: (([String: Any]) -> Void)?
var onUserInfoReceived: (([String: Any]) -> Void)?
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error { if let error = error {
@@ -13,6 +15,22 @@ private class SessionDelegate: NSObject, WCSessionDelegate {
onActivationComplete?(activationState) 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 sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) { func sessionDidDeactivate(_ session: WCSession) {
@@ -25,6 +43,7 @@ public class WatchConnectivityModule: Module {
private let delegate = SessionDelegate() private let delegate = SessionDelegate()
private var isActivated = false private var isActivated = false
private var pendingPromises: [(() -> Void)] = [] private var pendingPromises: [(() -> Void)] = []
private var pendingEvents: [[String: Any]] = []
// WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary // WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary
private func cleanForWCSession(_ obj: Any) -> Any { private func cleanForWCSession(_ obj: Any) -> Any {
@@ -57,6 +76,12 @@ public class WatchConnectivityModule: Module {
pending.forEach { $0() } 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() session.activate()
self.session = session self.session = session
} }
@@ -93,8 +118,6 @@ public class WatchConnectivityModule: Module {
print("[WatchConnectivity] sendWorkoutPlan: isReachable=\(session.isReachable), activationState=\(session.activationState.rawValue)") 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 { guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else {
promise.resolve(["status": "sent"]) promise.resolve(["status": "sent"])
return return
@@ -107,7 +130,6 @@ public class WatchConnectivityModule: Module {
promise.resolve(["status": "sent"]) promise.resolve(["status": "sent"])
} }
// Check actual session state, not our flag (which has a race condition)
if session.activationState == .activated { if session.activationState == .activated {
doSend() doSend()
} else { } else {
@@ -126,18 +148,20 @@ public class WatchConnectivityModule: Module {
var message = data var message = data
message["type"] = "workoutCompleted" message["type"] = "workoutCompleted"
guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else {
promise.resolve(["status": "sent"])
return
}
if session.isReachable { if session.isReachable {
session.sendMessage(message, replyHandler: { reply in session.sendMessage(cleaned, replyHandler: nil, errorHandler: { error in
promise.resolve(reply) print("[WatchConnectivity] sendMessage failed: \(error), falling back to transferUserInfo")
}, errorHandler: { error in session.transferUserInfo(cleaned)
print("[WatchConnectivity] sendMessage failed, falling back to transferUserInfo: \(error)")
session.transferUserInfo(message)
promise.resolve(["status": "queued"])
}) })
} else { } else {
session.transferUserInfo(message) session.transferUserInfo(cleaned)
promise.resolve(["status": "queued"])
} }
promise.resolve(["status": "sent"])
} }
if self.isActivated { if self.isActivated {
@@ -154,5 +178,20 @@ public class WatchConnectivityModule: Module {
AsyncFunction("updateApplicationContext") { (context: [String: Any]) in AsyncFunction("updateApplicationContext") { (context: [String: Any]) in
try? self.session?.updateApplicationContext(context) 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)
}
} }
} }
+45
View File
@@ -135,6 +135,51 @@ export async function getWorkoutLaps(workoutId: string) {
return db.select().from(laps).where(eq(laps.workoutId, workoutId)); 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 ─────────────────────────────────────────────────── // ─── Stats ───────────────────────────────────────────────────
export async function getWeeklyStats() { export async function getWeeklyStats() {
@@ -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<ReturnType<typeof setInterval> | 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);
}
};
}, []);
}
+9 -1
View File
@@ -8,11 +8,13 @@ struct ContentView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
if workoutManager.isWorkoutActive { if workoutManager.isWorkoutActive {
ActiveWorkoutView(workoutManager: workoutManager) ActiveWorkoutView(workoutManager: workoutManager)
} else if workoutManager.selectedWorkout != nil {
WorkoutDetailView(workoutManager: workoutManager)
} else { } else {
WorkoutListView(workoutManager: workoutManager) 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 { if workoutManager.completedWorkouts.count > 0 {
@@ -81,6 +86,9 @@ struct WorkoutListView: View {
.font(.caption) .font(.caption)
} }
} }
.onDelete { indexSet in
workoutManager.deleteCompletedWorkouts(at: indexSet)
}
} }
} }
} }
+63 -5
View File
@@ -24,24 +24,52 @@ struct SwimView: View {
workoutManager.currentSetIndex < workout.sets.count { workoutManager.currentSetIndex < workout.sets.count {
let currentSet = workout.sets[workoutManager.currentSetIndex] let currentSet = workout.sets[workoutManager.currentSetIndex]
// Set/Rep info
HStack {
Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)") Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("·")
.foregroundColor(.secondary)
Text("Rep \(workoutManager.currentRep + 1)/\(currentSet.repCount)")
.font(.caption2)
.foregroundColor(.secondary)
}
// Stroke name
Text(currentSet.stroke) Text(currentSet.stroke)
.font(.headline) .font(.headline)
.foregroundColor(.cyan) .foregroundColor(.cyan)
Text("Rep \(workoutManager.currentRep + 1)/\(currentSet.repCount)") // Current set distance
.font(.subheadline) Text("\(Int(workoutManager.currentSetDistanceSwum))/\(Int(workoutManager.currentSetTotalDistance))m")
Text("\(currentSet.distancePerRep)m")
.font(.system(size: 36, weight: .bold, design: .rounded)) .font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundColor(.cyan) .foregroundColor(.cyan)
} }
// Total distance
HStack(spacing: 4) {
Image(systemName: "figure.pool.swim")
.font(.caption)
.foregroundColor(.cyan)
Text("\(Int(workoutManager.totalDistanceSwum))m total")
.font(.caption)
.foregroundColor(.secondary)
}
// Elapsed time
Text(formatTime(workoutManager.elapsedSeconds)) Text(formatTime(workoutManager.elapsedSeconds))
.font(.system(.title, design: .monospaced)) .font(.system(.title3, design: .monospaced))
// Calories + Heart rate
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.font(.caption)
Text("\(Int(workoutManager.estimatedCalories))")
.font(.caption)
}
if workoutManager.heartRate > 0 { if workoutManager.heartRate > 0 {
HStack(spacing: 4) { HStack(spacing: 4) {
@@ -52,7 +80,17 @@ struct SwimView: View {
.font(.caption) .font(.caption)
} }
} }
}
// Lap detection indicator
if workoutManager.lapDetected {
Text("Lap detected!")
.font(.caption)
.foregroundColor(.green)
.transition(.opacity)
}
HStack(spacing: 12) {
Button { Button {
workoutManager.completeLap() workoutManager.completeLap()
} label: { } label: {
@@ -62,6 +100,7 @@ struct SwimView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.cyan) .tint(.cyan)
}
Button("End") { Button("End") {
workoutManager.endWorkout() workoutManager.endWorkout()
@@ -98,6 +137,25 @@ struct RestView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .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)
} }
} }
@@ -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)
}
}
+122 -6
View File
@@ -3,6 +3,7 @@ import SwiftUI
import HealthKit import HealthKit
import WatchKit import WatchKit
import WatchConnectivity import WatchConnectivity
import CoreMotion
class WorkoutManager: ObservableObject { class WorkoutManager: ObservableObject {
@Published var isWorkoutActive = false @Published var isWorkoutActive = false
@@ -25,6 +26,14 @@ class WorkoutManager: ObservableObject {
private var lapStartTime: Date? private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = [] 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() { init() {
healthStore = HKHealthStore() healthStore = HKHealthStore()
} }
@@ -60,6 +69,22 @@ class WorkoutManager: ObservableObject {
saveWorkoutsToStorage() 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 // MARK: - Workout Selection
func selectWorkout(_ workout: StoredWorkout) { func selectWorkout(_ workout: StoredWorkout) {
@@ -76,19 +101,25 @@ class WorkoutManager: ObservableObject {
currentRep = 0 currentRep = 0
elapsedSeconds = 0 elapsedSeconds = 0
completedLaps = [] completedLaps = []
wasStationary = false
stationarySince = nil
startHealthKitWorkout() startHealthKitWorkout()
startTimer() startTimer()
startLapDetection()
WKInterfaceDevice.current().enableWaterLock()
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
func pauseWorkout() { func pauseWorkout() {
workoutTimer?.invalidate() workoutTimer?.invalidate()
stopLapDetection()
WKInterfaceDevice.current().play(.notification) WKInterfaceDevice.current().play(.notification)
} }
func resumeWorkout() { func resumeWorkout() {
startTimer() startTimer()
startLapDetection()
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
@@ -97,6 +128,7 @@ class WorkoutManager: ObservableObject {
restTimer?.invalidate() restTimer?.invalidate()
isWorkoutActive = false isWorkoutActive = false
stopLapDetection()
endHealthKitWorkout() endHealthKitWorkout()
syncCompletedWorkout() syncCompletedWorkout()
WKInterfaceDevice.current().play(.success) 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 // MARK: - Timer
private func startTimer() { private func startTimer() {
@@ -220,6 +336,8 @@ class WorkoutManager: ObservableObject {
let workoutSummary: [String: Any] = [ let workoutSummary: [String: Any] = [
"type": "workoutCompleted", "type": "workoutCompleted",
"workoutId": selectedWorkout?.id ?? "", "workoutId": selectedWorkout?.id ?? "",
"name": selectedWorkout?.name ?? "Workout",
"poolLength": selectedWorkout?.poolLength ?? 0,
"laps": completedLaps.map { lap in "laps": completedLaps.map { lap in
[ [
"setPosition": lap.setPosition, "setPosition": lap.setPosition,
@@ -230,6 +348,8 @@ class WorkoutManager: ObservableObject {
] as [String: Any] ] as [String: Any]
}, },
"duration": elapsedSeconds, "duration": elapsedSeconds,
"totalDistance": Int(totalDistanceSwum),
"calories": Int(estimatedCalories),
] ]
var completed = completedWorkouts var completed = completedWorkouts
@@ -242,13 +362,9 @@ class WorkoutManager: ObservableObject {
UserDefaults.standard.set(data, forKey: "completedWorkouts") UserDefaults.standard.set(data, forKey: "completedWorkouts")
} }
if WCSession.default.isReachable { // Always use transferUserInfo for reliable delivery
WCSession.default.sendMessage(workoutSummary, replyHandler: nil) { error in
print("Failed to sync to phone: \(error)")
}
} else {
WCSession.default.transferUserInfo(workoutSummary) WCSession.default.transferUserInfo(workoutSummary)
} print("[WorkoutManager] Synced completed workout to phone")
} }
} }