Skip to content

Commit 486bbce

Browse files
Merge pull request #4 from quickpose/custom-exercises
Custom Exercises
2 parents 157f4ed + c10fd40 commit 486bbce

File tree

13 files changed

+728
-35
lines changed

13 files changed

+728
-35
lines changed

.DS_Store

0 Bytes
Binary file not shown.

FitCount.xcodeproj/project.pbxproj

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
6C3A46602ECE21970013F19A /* FrontPushupExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3A465F2ECE21970013F19A /* FrontPushupExercise.swift */; };
11+
6C88C6772E68604700223401 /* CustomExerciseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C88C6752E68604700223401 /* CustomExerciseEngine.swift */; };
12+
6C88C6782E68604700223401 /* SideTiltsExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C88C6762E68604700223401 /* SideTiltsExercise.swift */; };
13+
6C88C6EF2E69BDBE00223401 /* KneeRaisesExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */; };
1014
920A3EEC2A1F6E0E00EC6FC9 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */; };
1115
920A3EF12A1F7C2300EC6FC9 /* WorkoutResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920A3EF02A1F7C2300EC6FC9 /* WorkoutResultsView.swift */; };
1216
920A3EF62A20B14100EC6FC9 /* PagerTabStripView in Frameworks */ = {isa = PBXBuildFile; productRef = 920A3EF52A20B14100EC6FC9 /* PagerTabStripView */; };
@@ -30,6 +34,10 @@
3034
/* End PBXBuildFile section */
3135

3236
/* Begin PBXFileReference section */
37+
6C3A465F2ECE21970013F19A /* FrontPushupExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontPushupExercise.swift; sourceTree = "<group>"; };
38+
6C88C6752E68604700223401 /* CustomExerciseEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomExerciseEngine.swift; sourceTree = "<group>"; };
39+
6C88C6762E68604700223401 /* SideTiltsExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideTiltsExercise.swift; sourceTree = "<group>"; };
40+
6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KneeRaisesExercise.swift; sourceTree = "<group>"; };
3341
920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = "<group>"; };
3442
920A3EF02A1F7C2300EC6FC9 /* WorkoutResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutResultsView.swift; sourceTree = "<group>"; };
3543
9215905E2A2A10BF001254BC /* InstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsView.swift; sourceTree = "<group>"; };
@@ -121,6 +129,10 @@
121129
924D6E232A289EB600227183 /* About */,
122130
92CACFD02A1B7DD100DA2B40 /* FitCountApp.swift */,
123131
92CACFD22A1B7DD100DA2B40 /* ContentView.swift */,
132+
6C88C6752E68604700223401 /* CustomExerciseEngine.swift */,
133+
6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */,
134+
6C88C6762E68604700223401 /* SideTiltsExercise.swift */,
135+
6C3A465F2ECE21970013F19A /* FrontPushupExercise.swift */,
124136
92F2D1FA2A1D0C8400EC1B81 /* Text2Speech.swift */,
125137
924D6E212A264B3600227183 /* JsonWriter.swift */,
126138
920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */,
@@ -224,8 +236,12 @@
224236
buildActionMask = 2147483647;
225237
files = (
226238
92CACFD32A1B7DD100DA2B40 /* ContentView.swift in Sources */,
239+
6C3A46602ECE21970013F19A /* FrontPushupExercise.swift in Sources */,
227240
9215905F2A2A10BF001254BC /* InstructionsView.swift in Sources */,
228241
92CACFD12A1B7DD100DA2B40 /* FitCountApp.swift in Sources */,
242+
6C88C6772E68604700223401 /* CustomExerciseEngine.swift in Sources */,
243+
6C88C6782E68604700223401 /* SideTiltsExercise.swift in Sources */,
244+
6C88C6EF2E69BDBE00223401 /* KneeRaisesExercise.swift in Sources */,
229245
924D6E282A29F90400227183 /* VolumeChangeView.swift in Sources */,
230246
924D6E252A289ED700227183 /* AboutView.swift in Sources */,
231247
920A3EF12A1F7C2300EC6FC9 /* WorkoutResultsView.swift in Sources */,
@@ -367,23 +383,24 @@
367383
ENABLE_PREVIEWS = YES;
368384
GENERATE_INFOPLIST_FILE = YES;
369385
INFOPLIST_FILE = FitCount/Info.plist;
370-
INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movements feedback during a workout";
386+
INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movement feedback during a workout";
371387
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
372388
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
373389
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
374390
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
375391
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
376-
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
392+
IPHONEOS_DEPLOYMENT_TARGET = 18;
377393
LD_RUNPATH_SEARCH_PATHS = (
378394
"$(inherited)",
379395
"@executable_path/Frameworks",
380396
);
381-
MARKETING_VERSION = 0.4;
382-
PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.demo;
397+
MARKETING_VERSION = 1.0;
398+
PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.repcount;
383399
PRODUCT_NAME = "$(TARGET_NAME)";
384400
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
385401
SUPPORTS_MACCATALYST = NO;
386402
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
403+
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
387404
SWIFT_EMIT_LOC_STRINGS = YES;
388405
SWIFT_VERSION = 5.0;
389406
TARGETED_DEVICE_FAMILY = 1;
@@ -401,23 +418,24 @@
401418
ENABLE_PREVIEWS = YES;
402419
GENERATE_INFOPLIST_FILE = YES;
403420
INFOPLIST_FILE = FitCount/Info.plist;
404-
INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movements feedback during a workout";
421+
INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movement feedback during a workout";
405422
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
406423
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
407424
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
408425
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
409426
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
410-
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
427+
IPHONEOS_DEPLOYMENT_TARGET = 18;
411428
LD_RUNPATH_SEARCH_PATHS = (
412429
"$(inherited)",
413430
"@executable_path/Frameworks",
414431
);
415-
MARKETING_VERSION = 0.4;
416-
PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.demo;
432+
MARKETING_VERSION = 1.0;
433+
PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.repcount;
417434
PRODUCT_NAME = "$(TARGET_NAME)";
418435
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
419436
SUPPORTS_MACCATALYST = NO;
420437
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
438+
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
421439
SWIFT_EMIT_LOC_STRINGS = YES;
422440
SWIFT_VERSION = 5.0;
423441
TARGETED_DEVICE_FAMILY = 1;

FitCount/.DS_Store

6 KB
Binary file not shown.

FitCount/Assets.xcassets/AppIcon.appiconset/Contents.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"images" : [
33
{
4-
"filename" : "dsyntha_ios_app_abstract_icon_of_a_fitness_app_barbells_blue_an_57a91e90-e96e-48d8-8383-e2f346456c15.png",
4+
"filename" : "fitcountlogo.png",
55
"idiom" : "universal",
66
"platform" : "ios",
77
"size" : "1024x1024"
388 KB
Loading
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+
}

0 commit comments

Comments
 (0)