diff --git a/.DS_Store b/.DS_Store index 0917de6..3981e0c 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/FitCount.xcodeproj/project.pbxproj b/FitCount.xcodeproj/project.pbxproj index 86448c1..ab78052 100644 --- a/FitCount.xcodeproj/project.pbxproj +++ b/FitCount.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 6C3A46602ECE21970013F19A /* FrontPushupExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3A465F2ECE21970013F19A /* FrontPushupExercise.swift */; }; + 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 */; }; @@ -30,6 +34,10 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 6C3A465F2ECE21970013F19A /* FrontPushupExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontPushupExercise.swift; sourceTree = ""; }; + 6C88C6752E68604700223401 /* CustomExerciseEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomExerciseEngine.swift; sourceTree = ""; }; + 6C88C6762E68604700223401 /* SideTiltsExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideTiltsExercise.swift; sourceTree = ""; }; + 6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KneeRaisesExercise.swift; sourceTree = ""; }; 920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = ""; }; 920A3EF02A1F7C2300EC6FC9 /* WorkoutResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutResultsView.swift; sourceTree = ""; }; 9215905E2A2A10BF001254BC /* InstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionsView.swift; sourceTree = ""; }; @@ -121,6 +129,10 @@ 924D6E232A289EB600227183 /* About */, 92CACFD02A1B7DD100DA2B40 /* FitCountApp.swift */, 92CACFD22A1B7DD100DA2B40 /* ContentView.swift */, + 6C88C6752E68604700223401 /* CustomExerciseEngine.swift */, + 6C88C6EE2E69BDBE00223401 /* KneeRaisesExercise.swift */, + 6C88C6762E68604700223401 /* SideTiltsExercise.swift */, + 6C3A465F2ECE21970013F19A /* FrontPushupExercise.swift */, 92F2D1FA2A1D0C8400EC1B81 /* Text2Speech.swift */, 924D6E212A264B3600227183 /* JsonWriter.swift */, 920A3EEB2A1F6E0E00EC6FC9 /* Timer.swift */, @@ -224,8 +236,12 @@ buildActionMask = 2147483647; files = ( 92CACFD32A1B7DD100DA2B40 /* ContentView.swift in Sources */, + 6C3A46602ECE21970013F19A /* FrontPushupExercise.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 */, @@ -367,23 +383,24 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FitCount/Info.plist; - INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movements feedback during a workout"; + INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movement feedback during a workout"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 18; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4; - PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.demo; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.repcount; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -401,23 +418,24 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FitCount/Info.plist; - INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movements feedback during a workout"; + INFOPLIST_KEY_NSCameraUsageDescription = "We use your camera to provide movement feedback during a workout"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 18; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4; - PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.demo; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ai.quickpose.repcount; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; diff --git a/FitCount.xcodeproj/project.xcworkspace/xcuserdata/ljubicic.xcuserdatad/UserInterfaceState.xcuserstate b/FitCount.xcodeproj/project.xcworkspace/xcuserdata/ljubicic.xcuserdatad/UserInterfaceState.xcuserstate index 8a10961..2785bfa 100644 Binary files a/FitCount.xcodeproj/project.xcworkspace/xcuserdata/ljubicic.xcuserdatad/UserInterfaceState.xcuserstate and b/FitCount.xcodeproj/project.xcworkspace/xcuserdata/ljubicic.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/FitCount/.DS_Store b/FitCount/.DS_Store new file mode 100644 index 0000000..d936aec Binary files /dev/null and b/FitCount/.DS_Store differ diff --git a/FitCount/Assets.xcassets/AppIcon.appiconset/Contents.json b/FitCount/Assets.xcassets/AppIcon.appiconset/Contents.json index 2ccaea0..4a464f2 100644 --- a/FitCount/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/FitCount/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "dsyntha_ios_app_abstract_icon_of_a_fitness_app_barbells_blue_an_57a91e90-e96e-48d8-8383-e2f346456c15.png", + "filename" : "fitcountlogo.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/FitCount/Assets.xcassets/AppIcon.appiconset/dsyntha_ios_app_abstract_icon_of_a_fitness_app_barbells_blue_an_57a91e90-e96e-48d8-8383-e2f346456c15.png b/FitCount/Assets.xcassets/AppIcon.appiconset/dsyntha_ios_app_abstract_icon_of_a_fitness_app_barbells_blue_an_57a91e90-e96e-48d8-8383-e2f346456c15.png deleted file mode 100644 index 173c74c..0000000 Binary files a/FitCount/Assets.xcassets/AppIcon.appiconset/dsyntha_ios_app_abstract_icon_of_a_fitness_app_barbells_blue_an_57a91e90-e96e-48d8-8383-e2f346456c15.png and /dev/null differ diff --git a/FitCount/Assets.xcassets/AppIcon.appiconset/fitcountlogo.png b/FitCount/Assets.xcassets/AppIcon.appiconset/fitcountlogo.png new file mode 100644 index 0000000..57ed87b Binary files /dev/null and b/FitCount/Assets.xcassets/AppIcon.appiconset/fitcountlogo.png differ diff --git a/FitCount/CustomExerciseEngine.swift b/FitCount/CustomExerciseEngine.swift new file mode 100644 index 0000000..f10db55 --- /dev/null +++ b/FitCount/CustomExerciseEngine.swift @@ -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 = [] + + 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 = [] + + 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: ", ") + } +} diff --git a/FitCount/FitCountApp.swift b/FitCount/FitCountApp.swift index a0d9d1d..6a1e027 100644 --- a/FitCount/FitCountApp.swift +++ b/FitCount/FitCountApp.swift @@ -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 = [ @@ -33,6 +40,9 @@ 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, + FrontPushupExercise.createExercise().exerciseDefinition, ] diff --git a/FitCount/FrontPushupExercise.swift b/FitCount/FrontPushupExercise.swift new file mode 100644 index 0000000..9cd0991 --- /dev/null +++ b/FitCount/FrontPushupExercise.swift @@ -0,0 +1,76 @@ +// +// FrontPush-upExercise.swift +// Generated by QuickPose Exercise Creator +// + +import Foundation +import QuickPoseCore + +// MARK: - Front Push-up Exercise Definition +class FrontPushupExercise { + static func createExercise(hideFeedback: Bool = true) -> CustomExercise { + let stages = [ + // Stage 1: Top Position (Arms Extended) + ExerciseStage( + id: "top_position", + name: "Top Position", + requirements: [ + .elbow(side: .right): AngleRange(min: 150, max: 210), + .shoulder(side: .right): AngleRange(min: 10, max: 90), + .elbow(side: .left): AngleRange(min: 150, max: 210), + .shoulder(side: .left): AngleRange(min: 10, max: 90), + ], + description: "Arms extended at the top of the push-up" + ), + // Stage 2: Bottom Position (Arms Bent) + ExerciseStage( + id: "bottom_position", + name: "Bottom Position", + requirements: [ + .elbow(side: .right): AngleRange(min: 50, max: 110), + .shoulder(side: .right): AngleRange(min: 50, max: 100), + .elbow(side: .left): AngleRange(min: 50, max: 110), + .shoulder(side: .left): AngleRange(min: 50, max: 100), + ], + description: "Arms bent at the bottom of the push-up" + ), + ExerciseStage( + id: "Top Position", + name: "Bottom Position", + requirements: [ + .elbow(side: .right): AngleRange(min: 50, max: 110), + .shoulder(side: .right): AngleRange(min: 50, max: 100), + .elbow(side: .left): AngleRange(min: 50, max: 110), + .shoulder(side: .left): AngleRange(min: 50, max: 100), + ], + description: "Arms bent at the bottom of the push-up" + ) + ] + + // Define the required QuickPose features for range of motion tracking + // Create custom styles for overlay + let lightOverlayStyle = QuickPose.Style(relativeFontSize: 0.0, relativeArcSize: 0.3, relativeLineWidth: 1.0) + let noOverlayStyle = QuickPose.Style(relativeFontSize: 0.0, relativeArcSize: 0.0, relativeLineWidth: 0.0) + + let requiredFeatures: [QuickPose.Feature] = [ + .rangeOfMotion(.elbow(side: .left, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.hip(side: .left, clockwiseDirection: true), style: noOverlayStyle), + .rangeOfMotion(.knee(side: .left, clockwiseDirection: true), style: noOverlayStyle), + .rangeOfMotion(.shoulder(side: .left, clockwiseDirection: false), style: lightOverlayStyle), + .rangeOfMotion(.elbow(side: .right, clockwiseDirection: false), style: lightOverlayStyle), + .rangeOfMotion(.hip(side: .right, clockwiseDirection: true), style: noOverlayStyle), + .rangeOfMotion(.knee(side: .right, clockwiseDirection: true), style: noOverlayStyle), + .rangeOfMotion(.shoulder(side: .right, clockwiseDirection: true), style: lightOverlayStyle), + .overlay(.wholeBody, style: noOverlayStyle) // Light body outline + ] + + return CustomExercise( + id: "front_push-up", + name: "Front Push-up", + description: "Front Push-up", + stages: stages, + requiredFeatures: requiredFeatures, + hideFeedback: hideFeedback + ) + } +} diff --git a/FitCount/KneeRaisesExercise.swift b/FitCount/KneeRaisesExercise.swift new file mode 100644 index 0000000..c537ca6 --- /dev/null +++ b/FitCount/KneeRaisesExercise.swift @@ -0,0 +1,92 @@ +// +// KneeRaisesExercise.swift +// FitCount +// +// Created by QuickPose.ai +// + +import Foundation +import QuickPoseCore + +// MARK: - Knee Raises Exercise Definition +class KneeRaisesExercise { + static func createExercise(hideFeedback: Bool = true) -> CustomExercise { + let stages = [ + // Stage 1: Start Position + ExerciseStage( + id: "start_position", + name: "Start Position", + requirements: [ + .knee(side: .left): AngleRange(min: 150, max: 200), + .knee(side: .right): AngleRange(min: 150, max: 200), + .elbow(side: .left): AngleRange(min: 150, max: 200), + .elbow(side: .right): AngleRange(min: 150, max: 200) + ], + description: "Stand upright with both knees and elbows in relaxed position" + ), + + // Stage 2: Right Knee Up + ExerciseStage( + id: "right_knee_up", + name: "Right Knee Up", + requirements: [ + .knee(side: .right): AngleRange(min: 340, max: 90), + .knee(side: .left): AngleRange(min: 150, max: 200), + .elbow(side: .right): AngleRange(min: 90, max: 180), + .elbow(side: .left): AngleRange(min: 90, max: 180) + ], + description: "Raise your right knee up while keeping elbows bent" + ), + + // Stage 3: Back to Middle + ExerciseStage( + id: "back_to_middle", + name: "Back to Middle", + requirements: [ + .knee(side: .left): AngleRange(min: 150, max: 200), + .knee(side: .right): AngleRange(min: 150, max: 200), + .elbow(side: .left): AngleRange(min: 150, max: 200), + .elbow(side: .right): AngleRange(min: 150, max: 200) + ], + description: "Return to center position with both knees and elbows relaxed" + ), + + // Stage 4: Left Knee Up + ExerciseStage( + id: "left_knee_up", + name: "Left Knee Up", + requirements: [ + .knee(side: .right): AngleRange(min: 150, max: 200), + .knee(side: .left): AngleRange(min: 340, max: 90), + .elbow(side: .right): AngleRange(min: 90, max: 180), + .elbow(side: .left): AngleRange(min: 90, max: 180) + ], + description: "Raise your left knee up while keeping elbows bent" + ) + ] + + // Define the required QuickPose features for range of motion tracking + // Create custom styles for overlay + let lightOverlayStyle = QuickPose.Style(relativeFontSize: 0.0, relativeArcSize: 0.0, relativeLineWidth: 1.0) + let noOverlayStyle = QuickPose.Style(relativeFontSize: 0.0, relativeArcSize: 0.0, relativeLineWidth: 0.0) + + let requiredFeatures: [QuickPose.Feature] = [ + // Knee tracking with clockwise direction as specified + .rangeOfMotion(.knee(side: .right, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.knee(side: .left, clockwiseDirection: true), style: lightOverlayStyle), + // Elbow tracking - Right elbow ACW (false), Left elbow CW (true) + .rangeOfMotion(.elbow(side: .right, clockwiseDirection: false), style: lightOverlayStyle), + .rangeOfMotion(.elbow(side: .left, clockwiseDirection: true), style: lightOverlayStyle), + .overlay(.wholeBody, style: noOverlayStyle) // Light body outline + ] + + return CustomExercise( + id: "knee_raises", + name: "Knee Raises", + description: "Alternating knee raises while maintaining elbow position to strengthen core and improve leg mobility.", + stages: stages, + requiredFeatures: requiredFeatures, + hideFeedback: hideFeedback + ) + } +} diff --git a/FitCount/SideTiltsExercise.swift b/FitCount/SideTiltsExercise.swift new file mode 100644 index 0000000..07bd4a5 --- /dev/null +++ b/FitCount/SideTiltsExercise.swift @@ -0,0 +1,156 @@ +// +// SideTiltsExercise.swift +// FitCount +// +// Created by QuickPose.ai +// + +import Foundation +import QuickPoseCore + +// MARK: - Side Tilts Exercise Definition +class SideTiltsExercise { + static func createExercise(hideFeedback: Bool = false) -> CustomExercise { + let stages = [ + // Stage 1: Start Position - Elbows Bent + ExerciseStage( + id: "start_position", + name: "Center Position", + requirements: [ + .elbow(side: .left): AngleRange(min: 70, max: 200), + .elbow(side: .right): AngleRange(min: 70, max: 200), + .knee(side: .left): AngleRange(min: 160, max: 200), + .knee(side: .right): AngleRange(min: 160, max: 200), + .shoulder(side: .left): AngleRange(min: 0, max: 90), + .shoulder(side: .right): AngleRange(min: 0, max: 90) + ], + description: "Stand upright with both elbows bent and arms at your sides" + ), + + // Stage 2: Right Arm Straight, Left Arm Bent +// ExerciseStage( +// id: "right_arm_straight", +// name: "Straighten Right Arm", +// requirements: [ +// .elbow(side: .left): AngleRange(min: 70, max: 150), +// .elbow(side: .right): AngleRange(min: 160, max: 200), +// .shoulder(side: .left): AngleRange(min: 30, max: 70), +// .shoulder(side: .right): AngleRange(min: 0, max: 90) +// ], +// description: "Straighten your right arm while keeping left arm bent" +// ), + + // Stage 3: Raising Right Arm (Midway) + ExerciseStage( + id: "raising_right_arm", + name: "Raise Right Arm", + requirements: [ + .elbow(side: .left): AngleRange(min: 70, max: 150), + .elbow(side: .right): AngleRange(min: 120, max: 200), + .shoulder(side: .left): AngleRange(min: 30, max: 70), + .shoulder(side: .right): AngleRange(min: 75, max: 160) + ], + description: "Raise your right arm higher while maintaining left arm position" + ), + + // Stage 4: Right Arm Leaning + ExerciseStage( + id: "right_arm_leaning", + name: "Lean Right", + requirements: [ + .elbow(side: .left): AngleRange(min: 70, max: 150), + .elbow(side: .right): AngleRange(min: 120, max: 180), + .shoulder(side: .left): AngleRange(min: 10, max: 50), + .shoulder(side: .right): AngleRange(min: 160, max: 200), + .hip(side: .left): AngleRange(min: 180, max: 220), + .hip(side: .right): AngleRange(min: 170, max: 210) + ], + description: "Lean to the right with full right arm extension" + ), + + // Stage 5: Return to Both Elbows Bent + ExerciseStage( + id: "return_to_center", + name: "Return to Center", + requirements: [ + .elbow(side: .left): AngleRange(min: 70, max: 200), + .elbow(side: .right): AngleRange(min: 70, max: 200), + .knee(side: .left): AngleRange(min: 160, max: 200), + .knee(side: .right): AngleRange(min: 160, max: 200), + .shoulder(side: .left): AngleRange(min: 0, max: 90), + .shoulder(side: .right): AngleRange(min: 0, max: 90) + ], + description: "Return to center position with both elbows bent" + ), + + // Stage 6: Left Arm Straight, Right Arm Bent +// ExerciseStage( +// id: "left_arm_straight", +// name: "Straighten Left Arm", +// requirements: [ +// .elbow(side: .left): AngleRange(min: 160, max: 200), +// .elbow(side: .right): AngleRange(min: 140, max: 240), +// .shoulder(side: .left): AngleRange(min: 0, max: 70), +// .shoulder(side: .right): AngleRange(min: 0, max: 120) +// ], +// description: "Straighten your left arm while keeping right arm bent" +// ), + + // Stage 7: Raising Left Arm (Midway) + ExerciseStage( + id: "raising_left_arm", + name: "Raise Left Arm", + requirements: [ + .elbow(side: .left): AngleRange(min: 120, max: 200), + .elbow(side: .right): AngleRange(min: 140, max: 240), + .shoulder(side: .left): AngleRange(min: 60, max: 150), + .shoulder(side: .right): AngleRange(min: 0, max: 120) + ], + description: "Raise your left arm higher while maintaining right arm position" + ), + + // Stage 8: Left Arm Leaning + ExerciseStage( + id: "left_arm_leaning", + name: "Lean Left", + requirements: [ + .elbow(side: .left): AngleRange(min: 160, max: 220), + .elbow(side: .right): AngleRange(min: 160, max: 260), + .shoulder(side: .left): AngleRange(min: 170, max: 210), + .shoulder(side: .right): AngleRange(min: 0, max: 120), + .hip(side: .left): AngleRange(min: 120, max: 180), + .hip(side: .right): AngleRange(min: 100, max: 190) + ], + description: "Lean to the left with full left arm extension" + ) + ] + + // Define the required QuickPose features for range of motion tracking + // Using simplified approach - request standard measurements for all joints + + // Create custom styles for overlay + let lightOverlayStyle = QuickPose.Style(relativeFontSize: 0.0, relativeArcSize: 0.0, relativeLineWidth: 1.0) + let noOverlayStyle = QuickPose.Style(relativeFontSize: 0.0, relativeArcSize: 0.0, relativeLineWidth: 0.0) + + let requiredFeatures: [QuickPose.Feature] = [ + .rangeOfMotion(.shoulder(side: .left, clockwiseDirection: false), style: lightOverlayStyle), + .rangeOfMotion(.shoulder(side: .right, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.elbow(side: .left, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.elbow(side: .right, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.knee(side: .left, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.knee(side: .right, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.hip(side: .left, clockwiseDirection: true), style: lightOverlayStyle), + .rangeOfMotion(.hip(side: .right, clockwiseDirection: true), style: lightOverlayStyle), + .overlay(.wholeBody, style: noOverlayStyle) // Light body outline + ] + + return CustomExercise( + id: "side_tilts", + name: "Side Tilts", + description: "Lean from side to side with alternating arm movements to engage your core and improve lateral flexibility.", + stages: stages, + requiredFeatures: requiredFeatures, + hideFeedback: hideFeedback + ) + } +} diff --git a/FitCount/Workout/QuickPoseBasicView.swift b/FitCount/Workout/QuickPoseBasicView.swift index 919874e..432f525 100644 --- a/FitCount/Workout/QuickPoseBasicView.swift +++ b/FitCount/Workout/QuickPoseBasicView.swift @@ -37,7 +37,7 @@ enum ViewState: Equatable { } struct QuickPoseBasicView: View { - private var quickPose = QuickPose(sdkKey: "PLACE YOUR SDK KEY HERE") // register for your free key at https://dev.quickpose.ai + private var quickPose = QuickPose(sdkKey: "ENTER YOUR SDK KEY HERE") // register for your free key at https://dev.quickpose.ai @EnvironmentObject var viewModel: ViewModel @EnvironmentObject var sessionConfig: SessionConfig @@ -45,6 +45,7 @@ struct QuickPoseBasicView: View { @State private var feedbackText: String? = nil @State private var counter = QuickPoseThresholdCounter() + @State private var customExerciseEngine: CustomExerciseEngine? @State private var state: ViewState = .startVolume @State private var boundingBoxVisibility = 1.0 @@ -53,6 +54,14 @@ struct QuickPoseBasicView: View { static let synthesizer = AVSpeechSynthesizer() + // Computed property to determine if feedback should be shown + private var shouldShowFeedback: Bool { + if sessionConfig.exercise.isCustomExercise { + return !(customExerciseEngine?.exercise.hideFeedback ?? false) + } + return true + } + func canMoveFromBoundingBox(landmarks: QuickPose.Landmarks) -> Bool { let xsInBox = landmarks.allLandmarksForBody().allSatisfy { 0.5 - (0.8/2) < $0.x && $0.x < 0.5 + (0.8/2) } let ysInBox = landmarks.allLandmarksForBody().allSatisfy { 0.5 - (0.9/2) < $0.y && $0.y < 0.5 + (0.9/2) } @@ -60,6 +69,41 @@ struct QuickPoseBasicView: View { return xsInBox && ysInBox } + // Extract the feedback overlay into a separate function to avoid complex type checking + @ViewBuilder + private func feedbackOverlay(for feedbackText: String) -> some View { + VStack { + Spacer().frame(height: 60) // Add some top spacing + + // Determine colors based on feedback message + let isCorrect = feedbackText.contains("✅") + let isAdjustment = feedbackText.contains("🔴") + let backgroundColor = isCorrect ? Color.green.opacity(0.8) : + isAdjustment ? Color.red.opacity(0.8) : + Color.black.opacity(0.8) + let borderColor = isCorrect ? Color.green : + isAdjustment ? Color.red : + Color("AccentColor") + + Text(feedbackText) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(borderColor, lineWidth: 2) + ) + ) + .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) + Spacer() + } + } + var body: some View { GeometryReader { geometry in VStack { @@ -162,13 +206,9 @@ struct QuickPoseBasicView: View { .background(Color("AccentColor")) } } - .overlay(alignment: .center) { - if case .exercise = state { - if let feedbackText = feedbackText { - Text(feedbackText) - .font(.system(size: 26, weight: .semibold)).foregroundColor(.white) - .padding(16) - } + .overlay(alignment: .top) { + if case .exercise = state, let feedbackText = feedbackText, shouldShowFeedback { + feedbackOverlay(for: feedbackText) } } @@ -214,41 +254,79 @@ struct QuickPoseBasicView: View { state = .introBoundingBox } case .introExercise(_): + // Initialize custom exercise engine if needed + if sessionConfig.exercise.isCustomExercise { + if sessionConfig.exercise.name == "Side Tilts" { + customExerciseEngine = CustomExerciseEngine(exercise: SideTiltsExercise.createExercise()) + } else if sessionConfig.exercise.name == "Knee Raises" { + customExerciseEngine = CustomExerciseEngine(exercise: KneeRaisesExercise.createExercise()) + } else if sessionConfig.exercise.name == "Front Push-up" { + customExerciseEngine = CustomExerciseEngine(exercise: FrontPushupExercise.createExercise()) + } + } DispatchQueue.main.asyncAfter(deadline: .now()+0.5) { state = .exercise(SessionData(count: 0, seconds: 0), enterTime: Date()) } case .exercise(_, let enterDate): let secondsElapsed = Int(-enterDate.timeIntervalSinceNow) - if let feedback = feedback[sessionConfig.exercise.features.first!] { - feedbackText = feedback.displayString - } else { - feedbackText = nil - - if case .fitness = sessionConfig.exercise.features.first, let result = features[sessionConfig.exercise.features.first!] { - _ = counter.count(result.value) { newState in - if !newState.isEntered { - Text2Speech(text: "\(counter.state.count)").say() - DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { - withAnimation(.easeInOut(duration: 0.1)) { - countScale = 2.0 + var currentCount = 0 + + if sessionConfig.exercise.isCustomExercise { + // Handle custom exercises + if let customEngine = customExerciseEngine { + currentCount = customEngine.processFrame(features: features) + feedbackText = customEngine.feedbackMessage + + // Handle rep completion feedback + if customEngine.newRepCompleted { + Text2Speech(text: "\(currentCount)").say() + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + withAnimation(.easeInOut(duration: 0.1)) { + countScale = 2.0 + } + DispatchQueue.main.asyncAfter(deadline: .now()+0.4) { + withAnimation(.easeInOut(duration: 0.2)) { + countScale = 1.0 } - DispatchQueue.main.asyncAfter(deadline: .now()+0.4) { - withAnimation(.easeInOut(duration: 0.2)) { - countScale = 1.0 + } + } + customEngine.newRepCompleted = false // Reset the flag + } + } + } else { + // Handle built-in exercises + if let feedback = feedback[sessionConfig.exercise.features.first!] { + feedbackText = feedback.displayString + } else { + feedbackText = nil + + if case .fitness = sessionConfig.exercise.features.first, let result = features[sessionConfig.exercise.features.first!] { + _ = counter.count(result.value) { newState in + if !newState.isEntered { + Text2Speech(text: "\(counter.state.count)").say() + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + withAnimation(.easeInOut(duration: 0.1)) { + countScale = 2.0 + } + DispatchQueue.main.asyncAfter(deadline: .now()+0.4) { + withAnimation(.easeInOut(duration: 0.2)) { + countScale = 1.0 + } } } } } } } + currentCount = counter.state.count } - let newResults = SessionData(count: counter.state.count, seconds: secondsElapsed) + let newResults = SessionData(count: currentCount, seconds: secondsElapsed) state = .exercise(newResults, enterTime: enterDate) // refresh view for every updated second var hasFinished = false if sessionConfig.useReps { - hasFinished = counter.state.count >= sessionConfig.nReps + hasFinished = currentCount >= sessionConfig.nReps } else { hasFinished = secondsElapsed >= sessionConfig.nSeconds + sessionConfig.nMinutes * 60 }