Skip to content

Commit db8ad2b

Browse files
committed
Custom Exercises
1 parent 157f4ed commit db8ad2b

File tree

8 files changed

+634
-26
lines changed

8 files changed

+634
-26
lines changed

.DS_Store

0 Bytes
Binary file not shown.

FitCount.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
6C88C6772E68604700223401 /* CustomExerciseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C88C6752E68604700223401 /* CustomExerciseEngine.swift */; };
11+
6C88C6782E68604700223401 /* SideTiltsExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C88C6762E68604700223401 /* SideTiltsExercise.swift */; };
12+
6C88C6EF2E69BDBE00223401 /* KneeRaisesExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */; };
1013
920A3EEC2A1F6E0E00EC6FC9 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */; };
1114
920A3EF12A1F7C2300EC6FC9 /* WorkoutResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920A3EF02A1F7C2300EC6FC9 /* WorkoutResultsView.swift */; };
1215
920A3EF62A20B14100EC6FC9 /* PagerTabStripView in Frameworks */ = {isa = PBXBuildFile; productRef = 920A3EF52A20B14100EC6FC9 /* PagerTabStripView */; };
@@ -30,6 +33,9 @@
3033
/* End PBXBuildFile section */
3134

3235
/* Begin PBXFileReference section */
36+
6C88C6752E68604700223401 /* CustomExerciseEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomExerciseEngine.swift; sourceTree = "<group>"; };
37+
6C88C6762E68604700223401 /* SideTiltsExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideTiltsExercise.swift; sourceTree = "<group>"; };
38+
6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KneeRaisesExercise.swift; sourceTree = "<group>"; };
3339
920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = "<group>"; };
3440
920A3EF02A1F7C2300EC6FC9 /* WorkoutResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutResultsView.swift; sourceTree = "<group>"; };
3541
9215905E2A2A10BF001254BC /* InstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsView.swift; sourceTree = "<group>"; };
@@ -121,6 +127,9 @@
121127
924D6E232A289EB600227183 /* About */,
122128
92CACFD02A1B7DD100DA2B40 /* FitCountApp.swift */,
123129
92CACFD22A1B7DD100DA2B40 /* ContentView.swift */,
130+
6C88C6752E68604700223401 /* CustomExerciseEngine.swift */,
131+
6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */,
132+
6C88C6762E68604700223401 /* SideTiltsExercise.swift */,
124133
92F2D1FA2A1D0C8400EC1B81 /* Text2Speech.swift */,
125134
924D6E212A264B3600227183 /* JsonWriter.swift */,
126135
920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */,
@@ -226,6 +235,9 @@
226235
92CACFD32A1B7DD100DA2B40 /* ContentView.swift in Sources */,
227236
9215905F2A2A10BF001254BC /* InstructionsView.swift in Sources */,
228237
92CACFD12A1B7DD100DA2B40 /* FitCountApp.swift in Sources */,
238+
6C88C6772E68604700223401 /* CustomExerciseEngine.swift in Sources */,
239+
6C88C6782E68604700223401 /* SideTiltsExercise.swift in Sources */,
240+
6C88C6EF2E69BDBE00223401 /* KneeRaisesExercise.swift in Sources */,
229241
924D6E282A29F90400227183 /* VolumeChangeView.swift in Sources */,
230242
924D6E252A289ED700227183 /* AboutView.swift in Sources */,
231243
920A3EF12A1F7C2300EC6FC9 /* WorkoutResultsView.swift in Sources */,
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
//
2+
// CustomExerciseEngine.swift
3+
// FitCount
4+
//
5+
// Created by QuickPose.ai
6+
//
7+
8+
import SwiftUI
9+
import QuickPoseCore
10+
import Foundation
11+
12+
// MARK: - Joint Angle Definitions
13+
enum JointSide: Hashable {
14+
case left, right
15+
}
16+
17+
struct AngleRange: Hashable {
18+
let min: Double
19+
let max: Double
20+
21+
func contains(_ angle: Double) -> Bool {
22+
// Handle ranges that cross the 0° boundary (e.g., 340° to 90°)
23+
if min > max {
24+
// Range crosses 0° boundary: angle is in range if it's >= min OR <= max
25+
return angle >= min || angle <= max
26+
} else {
27+
// Normal range: angle is in range if it's between min and max
28+
return angle >= min && angle <= max
29+
}
30+
}
31+
32+
var description: String {
33+
if min > max {
34+
// Show wraparound range more clearly
35+
return "\(Int(min))° - 0° - \(Int(max))°"
36+
} else {
37+
return "\(Int(min)) - \(Int(max))°"
38+
}
39+
}
40+
}
41+
42+
enum JointType: Hashable {
43+
case elbow(side: JointSide)
44+
case shoulder(side: JointSide)
45+
case knee(side: JointSide)
46+
case hip(side: JointSide)
47+
48+
var description: String {
49+
switch self {
50+
case .elbow(let side): return "\(side == .left ? "Left" : "Right") Elbow"
51+
case .shoulder(let side): return "\(side == .left ? "Left" : "Right") Shoulder"
52+
case .knee(let side): return "\(side == .left ? "Left" : "Right") Knee"
53+
case .hip(let side): return "\(side == .left ? "Left" : "Right") Hip"
54+
}
55+
}
56+
}
57+
58+
// MARK: - Exercise Stage Definition
59+
struct ExerciseStage {
60+
let id: String
61+
let name: String
62+
let requirements: [JointType: AngleRange]
63+
let description: String
64+
65+
func meetsRequirements(angles: [JointType: Double]) -> Bool {
66+
for (joint, range) in requirements {
67+
guard let angle = angles[joint] else { return false }
68+
if !range.contains(angle) { return false }
69+
}
70+
return true
71+
}
72+
}
73+
74+
// MARK: - Custom Exercise Definition
75+
struct CustomExercise {
76+
let id: String
77+
let name: String
78+
let description: String
79+
let stages: [ExerciseStage]
80+
let requiredFeatures: [QuickPose.Feature]
81+
let hideFeedback: Bool
82+
83+
init(id: String, name: String, description: String, stages: [ExerciseStage], requiredFeatures: [QuickPose.Feature], hideFeedback: Bool = false) {
84+
self.id = id
85+
self.name = name
86+
self.description = description
87+
self.stages = stages
88+
self.requiredFeatures = requiredFeatures
89+
self.hideFeedback = hideFeedback
90+
}
91+
92+
var exerciseDefinition: Exercise {
93+
return Exercise(
94+
name: name,
95+
details: description,
96+
features: requiredFeatures,
97+
isCustomExercise: true
98+
)
99+
}
100+
}
101+
102+
// MARK: - Custom Exercise Engine
103+
class CustomExerciseEngine: ObservableObject {
104+
@Published var currentReps: Int = 0
105+
@Published var currentStage: String = ""
106+
@Published var feedbackMessage: String = ""
107+
@Published var newRepCompleted: Bool = false
108+
@Published var incorrectJoints: Set<JointType> = []
109+
110+
internal var exercise: CustomExercise
111+
private var currentStageIndex: Int = 0
112+
private var lastRepTime: Date = Date()
113+
private var currentAngles: [JointType: Double] = [:]
114+
private var isInTransition: Bool = false
115+
116+
init(exercise: CustomExercise) {
117+
self.exercise = exercise
118+
self.currentStage = exercise.stages.first?.name ?? ""
119+
// Initialize with first stage feedback only if feedback is not hidden
120+
if !exercise.hideFeedback, let firstStage = exercise.stages.first {
121+
self.feedbackMessage = "🔴 \(firstStage.name)\nGet into position"
122+
}
123+
}
124+
125+
func processFrame(features: [QuickPose.Feature: QuickPose.FeatureResult]) -> Int {
126+
// Extract all range of motion angles
127+
updateAngles(from: features)
128+
129+
// Check current stage requirements
130+
let currentExerciseStage = exercise.stages[currentStageIndex]
131+
132+
if currentExerciseStage.meetsRequirements(angles: currentAngles) {
133+
if !isInTransition {
134+
// We've entered this stage
135+
isInTransition = true
136+
currentStage = currentExerciseStage.name
137+
138+
// Move to next stage
139+
let nextStageIndex = (currentStageIndex + 1) % exercise.stages.count
140+
141+
// If we've completed all stages, count a rep
142+
if nextStageIndex == 0 {
143+
currentReps += 1
144+
lastRepTime = Date()
145+
if !exercise.hideFeedback {
146+
feedbackMessage = "🎉 Rep \(currentReps) Complete!\nStarting over..."
147+
}
148+
newRepCompleted = true
149+
} else {
150+
let nextStage = exercise.stages[nextStageIndex]
151+
if !exercise.hideFeedback {
152+
feedbackMessage = "\(currentExerciseStage.name)\nNext: \(nextStage.name)"
153+
}
154+
newRepCompleted = false
155+
}
156+
157+
currentStageIndex = nextStageIndex
158+
}
159+
} else {
160+
isInTransition = false
161+
// Provide feedback on what's needed
162+
updateFeedback(for: currentExerciseStage)
163+
}
164+
165+
return currentReps
166+
}
167+
168+
private func updateAngles(from features: [QuickPose.Feature: QuickPose.FeatureResult]) {
169+
// Get the current stage requirements to determine which direction we need
170+
let currentStageRequirements = exercise.stages[currentStageIndex].requirements
171+
172+
for feature in features.keys {
173+
if let result = features[feature] {
174+
let angle = result.value
175+
176+
if case .rangeOfMotion(let joint, _) = feature {
177+
// For now, let's simplify and just use all range of motion measurements
178+
// The direction logic was too complex - let's see what measurements we actually get
179+
switch joint {
180+
case .shoulder(side: .left, _):
181+
currentAngles[.shoulder(side: .left)] = angle
182+
case .shoulder(side: .right, _):
183+
currentAngles[.shoulder(side: .right)] = angle
184+
case .elbow(side: .left, _):
185+
currentAngles[.elbow(side: .left)] = angle
186+
case .elbow(side: .right, _):
187+
currentAngles[.elbow(side: .right)] = angle
188+
case .knee(side: .left, _):
189+
currentAngles[.knee(side: .left)] = angle
190+
case .knee(side: .right, _):
191+
currentAngles[.knee(side: .right)] = angle
192+
case .hip(side: .left, _):
193+
currentAngles[.hip(side: .left)] = angle
194+
case .hip(side: .right, _):
195+
currentAngles[.hip(side: .right)] = angle
196+
default:
197+
break
198+
}
199+
}
200+
}
201+
}
202+
}
203+
204+
private func updateFeedback(for stage: ExerciseStage) {
205+
// Check if feedback should be hidden
206+
if exercise.hideFeedback {
207+
feedbackMessage = ""
208+
return
209+
}
210+
211+
var missingRequirements: [String] = []
212+
var incorrectJointsSet: Set<JointType> = []
213+
214+
for (joint, range) in stage.requirements {
215+
if let angle = currentAngles[joint] {
216+
if !range.contains(angle) {
217+
let jointName = joint.description.replacingOccurrences(of: "Left ", with: "L-").replacingOccurrences(of: "Right ", with: "R-")
218+
missingRequirements.append("\(jointName): \(Int(angle))° (need \(range.description))")
219+
incorrectJointsSet.insert(joint)
220+
}
221+
} else {
222+
let jointName = joint.description.replacingOccurrences(of: "Left ", with: "L-").replacingOccurrences(of: "Right ", with: "R-")
223+
missingRequirements.append("\(jointName): Not detected")
224+
incorrectJointsSet.insert(joint)
225+
}
226+
}
227+
228+
// Update the published set of incorrect joints
229+
incorrectJoints = incorrectJointsSet
230+
231+
if missingRequirements.isEmpty {
232+
feedbackMessage = "\(stage.name)\nPerfect! Moving to next stage"
233+
} else if missingRequirements.count == 1 {
234+
feedbackMessage = "🔴 \(stage.name)\n\(missingRequirements.first!)"
235+
} else if missingRequirements.count <= 3 {
236+
feedbackMessage = "🔴 \(stage.name)\n\(missingRequirements.prefix(2).joined(separator: "\n"))"
237+
} else {
238+
feedbackMessage = "🔴 \(stage.name)\nAdjust \(missingRequirements.count) joints"
239+
}
240+
}
241+
242+
func reset() {
243+
currentReps = 0
244+
currentStageIndex = 0
245+
currentStage = exercise.stages.first?.name ?? ""
246+
// Initialize with first stage feedback only if feedback is not hidden
247+
if !exercise.hideFeedback, let firstStage = exercise.stages.first {
248+
feedbackMessage = "🔴 \(firstStage.name)\nGet into position"
249+
} else {
250+
feedbackMessage = ""
251+
}
252+
isInTransition = false
253+
currentAngles.removeAll()
254+
incorrectJoints.removeAll()
255+
}
256+
257+
// Debug helper
258+
func getCurrentAngles() -> String {
259+
return currentAngles.map { joint, angle in
260+
"\(joint.description): \(Int(angle))°"
261+
}.joined(separator: ", ")
262+
}
263+
}

FitCount/FitCountApp.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ struct Exercise: Identifiable, Hashable {
1414
let name: String
1515
let details: String
1616
let features: [QuickPose.Feature]
17-
// Add more properties as needed
17+
let isCustomExercise: Bool
18+
19+
init(name: String, details: String, features: [QuickPose.Feature], isCustomExercise: Bool = false) {
20+
self.name = name
21+
self.details = details
22+
self.features = features
23+
self.isCustomExercise = isCustomExercise
24+
}
1825
}
1926

2027
let exercises = [
@@ -33,6 +40,8 @@ let exercises = [
3340
details: "Stand with feet shoulder-width apart, hold dumbbells at shoulder height, and press them overhead until arms are fully extended.",
3441
features: [.fitness(.overheadDumbbellPress), .overlay(.upperBody)]
3542
),
43+
SideTiltsExercise.createExercise().exerciseDefinition,
44+
KneeRaisesExercise.createExercise().exerciseDefinition,
3645
]
3746

3847

0 commit comments

Comments
 (0)