Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
12 changes: 12 additions & 0 deletions FitCount.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
objects = {

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

/* Begin PBXFileReference section */
6C88C6752E68604700223401 /* CustomExerciseEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomExerciseEngine.swift; sourceTree = "<group>"; };
6C88C6762E68604700223401 /* SideTiltsExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideTiltsExercise.swift; sourceTree = "<group>"; };
6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KneeRaisesExercise.swift; sourceTree = "<group>"; };
920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = "<group>"; };
920A3EF02A1F7C2300EC6FC9 /* WorkoutResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutResultsView.swift; sourceTree = "<group>"; };
9215905E2A2A10BF001254BC /* InstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -121,6 +127,9 @@
924D6E232A289EB600227183 /* About */,
92CACFD02A1B7DD100DA2B40 /* FitCountApp.swift */,
92CACFD22A1B7DD100DA2B40 /* ContentView.swift */,
6C88C6752E68604700223401 /* CustomExerciseEngine.swift */,
6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */,
6C88C6762E68604700223401 /* SideTiltsExercise.swift */,
92F2D1FA2A1D0C8400EC1B81 /* Text2Speech.swift */,
924D6E212A264B3600227183 /* JsonWriter.swift */,
920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */,
Expand Down Expand Up @@ -226,6 +235,9 @@
92CACFD32A1B7DD100DA2B40 /* ContentView.swift in Sources */,
9215905F2A2A10BF001254BC /* InstructionsView.swift in Sources */,
92CACFD12A1B7DD100DA2B40 /* FitCountApp.swift in Sources */,
6C88C6772E68604700223401 /* CustomExerciseEngine.swift in Sources */,
6C88C6782E68604700223401 /* SideTiltsExercise.swift in Sources */,
6C88C6EF2E69BDBE00223401 /* KneeRaisesExercise.swift in Sources */,
924D6E282A29F90400227183 /* VolumeChangeView.swift in Sources */,
924D6E252A289ED700227183 /* AboutView.swift in Sources */,
920A3EF12A1F7C2300EC6FC9 /* WorkoutResultsView.swift in Sources */,
Expand Down
Binary file not shown.
263 changes: 263 additions & 0 deletions FitCount/CustomExerciseEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
//
// CustomExerciseEngine.swift
// FitCount
//
// Created by QuickPose.ai
//

import SwiftUI
import QuickPoseCore
import Foundation

// MARK: - Joint Angle Definitions
enum JointSide: Hashable {
case left, right
}

struct AngleRange: Hashable {
let min: Double
let max: Double

func contains(_ angle: Double) -> Bool {
// Handle ranges that cross the 0° boundary (e.g., 340° to 90°)
if min > max {
// Range crosses 0° boundary: angle is in range if it's >= min OR <= max
return angle >= min || angle <= max
} else {
// Normal range: angle is in range if it's between min and max
return angle >= min && angle <= max
}
}

var description: String {
if min > max {
// Show wraparound range more clearly
return "\(Int(min))° - 0° - \(Int(max))°"
} else {
return "\(Int(min)) - \(Int(max))°"
}
}
}

enum JointType: Hashable {
case elbow(side: JointSide)
case shoulder(side: JointSide)
case knee(side: JointSide)
case hip(side: JointSide)

var description: String {
switch self {
case .elbow(let side): return "\(side == .left ? "Left" : "Right") Elbow"
case .shoulder(let side): return "\(side == .left ? "Left" : "Right") Shoulder"
case .knee(let side): return "\(side == .left ? "Left" : "Right") Knee"
case .hip(let side): return "\(side == .left ? "Left" : "Right") Hip"
}
}
}

// MARK: - Exercise Stage Definition
struct ExerciseStage {
let id: String
let name: String
let requirements: [JointType: AngleRange]
let description: String

func meetsRequirements(angles: [JointType: Double]) -> Bool {
for (joint, range) in requirements {
guard let angle = angles[joint] else { return false }
if !range.contains(angle) { return false }
}
return true
}
}

// MARK: - Custom Exercise Definition
struct CustomExercise {
let id: String
let name: String
let description: String
let stages: [ExerciseStage]
let requiredFeatures: [QuickPose.Feature]
let hideFeedback: Bool

init(id: String, name: String, description: String, stages: [ExerciseStage], requiredFeatures: [QuickPose.Feature], hideFeedback: Bool = false) {
self.id = id
self.name = name
self.description = description
self.stages = stages
self.requiredFeatures = requiredFeatures
self.hideFeedback = hideFeedback
}

var exerciseDefinition: Exercise {
return Exercise(
name: name,
details: description,
features: requiredFeatures,
isCustomExercise: true
)
}
}

// MARK: - Custom Exercise Engine
class CustomExerciseEngine: ObservableObject {
@Published var currentReps: Int = 0
@Published var currentStage: String = ""
@Published var feedbackMessage: String = ""
@Published var newRepCompleted: Bool = false
@Published var incorrectJoints: Set<JointType> = []

internal var exercise: CustomExercise
private var currentStageIndex: Int = 0
private var lastRepTime: Date = Date()
private var currentAngles: [JointType: Double] = [:]
private var isInTransition: Bool = false

init(exercise: CustomExercise) {
self.exercise = exercise
self.currentStage = exercise.stages.first?.name ?? ""
// Initialize with first stage feedback only if feedback is not hidden
if !exercise.hideFeedback, let firstStage = exercise.stages.first {
self.feedbackMessage = "🔴 \(firstStage.name)\nGet into position"
}
}

func processFrame(features: [QuickPose.Feature: QuickPose.FeatureResult]) -> Int {
// Extract all range of motion angles
updateAngles(from: features)

// Check current stage requirements
let currentExerciseStage = exercise.stages[currentStageIndex]

if currentExerciseStage.meetsRequirements(angles: currentAngles) {
if !isInTransition {
// We've entered this stage
isInTransition = true
currentStage = currentExerciseStage.name

// Move to next stage
let nextStageIndex = (currentStageIndex + 1) % exercise.stages.count

// If we've completed all stages, count a rep
if nextStageIndex == 0 {
currentReps += 1
lastRepTime = Date()
if !exercise.hideFeedback {
feedbackMessage = "🎉 Rep \(currentReps) Complete!\nStarting over..."
}
newRepCompleted = true
} else {
let nextStage = exercise.stages[nextStageIndex]
if !exercise.hideFeedback {
feedbackMessage = "✅ \(currentExerciseStage.name)\nNext: \(nextStage.name)"
}
newRepCompleted = false
}

currentStageIndex = nextStageIndex
}
} else {
isInTransition = false
// Provide feedback on what's needed
updateFeedback(for: currentExerciseStage)
}

return currentReps
}

private func updateAngles(from features: [QuickPose.Feature: QuickPose.FeatureResult]) {
// Get the current stage requirements to determine which direction we need
let currentStageRequirements = exercise.stages[currentStageIndex].requirements

for feature in features.keys {
if let result = features[feature] {
let angle = result.value

if case .rangeOfMotion(let joint, _) = feature {
// For now, let's simplify and just use all range of motion measurements
// The direction logic was too complex - let's see what measurements we actually get
switch joint {
case .shoulder(side: .left, _):
currentAngles[.shoulder(side: .left)] = angle
case .shoulder(side: .right, _):
currentAngles[.shoulder(side: .right)] = angle
case .elbow(side: .left, _):
currentAngles[.elbow(side: .left)] = angle
case .elbow(side: .right, _):
currentAngles[.elbow(side: .right)] = angle
case .knee(side: .left, _):
currentAngles[.knee(side: .left)] = angle
case .knee(side: .right, _):
currentAngles[.knee(side: .right)] = angle
case .hip(side: .left, _):
currentAngles[.hip(side: .left)] = angle
case .hip(side: .right, _):
currentAngles[.hip(side: .right)] = angle
default:
break
}
}
}
}
}

private func updateFeedback(for stage: ExerciseStage) {
// Check if feedback should be hidden
if exercise.hideFeedback {
feedbackMessage = ""
return
}

var missingRequirements: [String] = []
var incorrectJointsSet: Set<JointType> = []

for (joint, range) in stage.requirements {
if let angle = currentAngles[joint] {
if !range.contains(angle) {
let jointName = joint.description.replacingOccurrences(of: "Left ", with: "L-").replacingOccurrences(of: "Right ", with: "R-")
missingRequirements.append("\(jointName): \(Int(angle))° (need \(range.description))")
incorrectJointsSet.insert(joint)
}
} else {
let jointName = joint.description.replacingOccurrences(of: "Left ", with: "L-").replacingOccurrences(of: "Right ", with: "R-")
missingRequirements.append("\(jointName): Not detected")
incorrectJointsSet.insert(joint)
}
}

// Update the published set of incorrect joints
incorrectJoints = incorrectJointsSet

if missingRequirements.isEmpty {
feedbackMessage = "✅ \(stage.name)\nPerfect! Moving to next stage"
} else if missingRequirements.count == 1 {
feedbackMessage = "🔴 \(stage.name)\n\(missingRequirements.first!)"
} else if missingRequirements.count <= 3 {
feedbackMessage = "🔴 \(stage.name)\n\(missingRequirements.prefix(2).joined(separator: "\n"))"
} else {
feedbackMessage = "🔴 \(stage.name)\nAdjust \(missingRequirements.count) joints"
}
}

func reset() {
currentReps = 0
currentStageIndex = 0
currentStage = exercise.stages.first?.name ?? ""
// Initialize with first stage feedback only if feedback is not hidden
if !exercise.hideFeedback, let firstStage = exercise.stages.first {
feedbackMessage = "🔴 \(firstStage.name)\nGet into position"
} else {
feedbackMessage = ""
}
isInTransition = false
currentAngles.removeAll()
incorrectJoints.removeAll()
}

// Debug helper
func getCurrentAngles() -> String {
return currentAngles.map { joint, angle in
"\(joint.description): \(Int(angle))°"
}.joined(separator: ", ")
}
}
11 changes: 10 additions & 1 deletion FitCount/FitCountApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ struct Exercise: Identifiable, Hashable {
let name: String
let details: String
let features: [QuickPose.Feature]
// Add more properties as needed
let isCustomExercise: Bool

init(name: String, details: String, features: [QuickPose.Feature], isCustomExercise: Bool = false) {
self.name = name
self.details = details
self.features = features
self.isCustomExercise = isCustomExercise
}
}

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


Expand Down
Loading