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