feat(watch): add lap detection via accelerometer and workout sync to phone
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user