react-native-nitro-pose-exercises 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/NitroPoseExercises.podspec +32 -0
- package/README.md +538 -0
- package/android/CMakeLists.txt +31 -0
- package/android/build.gradle +121 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/java/com/margelo/nitro/nitroposeexercises/NitroPoseExercises.kt +535 -0
- package/android/src/main/java/com/margelo/nitro/nitroposeexercises/NitroPoseExercisesPackage.kt +22 -0
- package/ios/NitroPoseExercises.swift +527 -0
- package/lib/module/NitroPoseExercises.nitro.js +17 -0
- package/lib/module/NitroPoseExercises.nitro.js.map +1 -0
- package/lib/module/config/pushup.js +71 -0
- package/lib/module/config/pushup.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NitroPoseExercises.nitro.d.ts +97 -0
- package/lib/typescript/src/NitroPoseExercises.nitro.d.ts.map +1 -0
- package/lib/typescript/src/config/pushup.d.ts +3 -0
- package/lib/typescript/src/config/pushup.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/android/c++/JAngleDefinition.hpp +69 -0
- package/nitrogen/generated/android/c++/JAngleSnapshot.hpp +61 -0
- package/nitrogen/generated/android/c++/JExerciseConfig.hpp +166 -0
- package/nitrogen/generated/android/c++/JExercisePhase.hpp +67 -0
- package/nitrogen/generated/android/c++/JExerciseType.hpp +61 -0
- package/nitrogen/generated/android/c++/JFormFeedback.hpp +67 -0
- package/nitrogen/generated/android/c++/JFormRule.hpp +79 -0
- package/nitrogen/generated/android/c++/JFormSeverity.hpp +61 -0
- package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_ExercisePhase.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_FormFeedback.hpp +80 -0
- package/nitrogen/generated/android/c++/JFunc_void_HoldProgress.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_RepData.hpp +81 -0
- package/nitrogen/generated/android/c++/JFunc_void_SessionResult.hpp +85 -0
- package/nitrogen/generated/android/c++/JHoldProgress.hpp +65 -0
- package/nitrogen/generated/android/c++/JHybridNitroPoseExercisesSpec.cpp +311 -0
- package/nitrogen/generated/android/c++/JHybridNitroPoseExercisesSpec.hpp +87 -0
- package/nitrogen/generated/android/c++/JLandmark.hpp +69 -0
- package/nitrogen/generated/android/c++/JPhaseThreshold.hpp +71 -0
- package/nitrogen/generated/android/c++/JRepData.hpp +90 -0
- package/nitrogen/generated/android/c++/JSessionResult.hpp +120 -0
- package/nitrogen/generated/android/c++/JSessionStatus.hpp +67 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/AngleDefinition.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/AngleSnapshot.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/ExerciseConfig.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/ExercisePhase.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/ExerciseType.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/FormFeedback.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/FormRule.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/FormSeverity.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Func_void.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Func_void_ExercisePhase.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Func_void_FormFeedback.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Func_void_HoldProgress.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Func_void_RepData.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Func_void_SessionResult.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/HoldProgress.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/HybridNitroPoseExercisesSpec.kt +196 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/Landmark.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/PhaseThreshold.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/RepData.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/SessionResult.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/SessionStatus.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroposeexercises/nitroposeexercisesOnLoad.kt +35 -0
- package/nitrogen/generated/android/nitroposeexercises+autolinking.cmake +81 -0
- package/nitrogen/generated/android/nitroposeexercises+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nitroposeexercisesOnLoad.cpp +66 -0
- package/nitrogen/generated/android/nitroposeexercisesOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NitroPoseExercises+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroPoseExercises-Swift-Cxx-Bridge.cpp +100 -0
- package/nitrogen/generated/ios/NitroPoseExercises-Swift-Cxx-Bridge.hpp +449 -0
- package/nitrogen/generated/ios/NitroPoseExercises-Swift-Cxx-Umbrella.hpp +95 -0
- package/nitrogen/generated/ios/NitroPoseExercisesAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroPoseExercisesAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridNitroPoseExercisesSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroPoseExercisesSpecSwift.hpp +236 -0
- package/nitrogen/generated/ios/swift/AngleDefinition.swift +44 -0
- package/nitrogen/generated/ios/swift/AngleSnapshot.swift +34 -0
- package/nitrogen/generated/ios/swift/ExerciseConfig.swift +83 -0
- package/nitrogen/generated/ios/swift/ExercisePhase.swift +52 -0
- package/nitrogen/generated/ios/swift/ExerciseType.swift +44 -0
- package/nitrogen/generated/ios/swift/FormFeedback.swift +39 -0
- package/nitrogen/generated/ios/swift/FormRule.swift +54 -0
- package/nitrogen/generated/ios/swift/FormSeverity.swift +44 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ExercisePhase.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_FormFeedback.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_HoldProgress.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_RepData.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_SessionResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/HoldProgress.swift +39 -0
- package/nitrogen/generated/ios/swift/HybridNitroPoseExercisesSpec.swift +73 -0
- package/nitrogen/generated/ios/swift/HybridNitroPoseExercisesSpec_cxx.swift +483 -0
- package/nitrogen/generated/ios/swift/Landmark.swift +44 -0
- package/nitrogen/generated/ios/swift/PhaseThreshold.swift +44 -0
- package/nitrogen/generated/ios/swift/RepData.swift +50 -0
- package/nitrogen/generated/ios/swift/SessionResult.swift +66 -0
- package/nitrogen/generated/ios/swift/SessionStatus.swift +52 -0
- package/nitrogen/generated/shared/c++/AngleDefinition.hpp +95 -0
- package/nitrogen/generated/shared/c++/AngleSnapshot.hpp +87 -0
- package/nitrogen/generated/shared/c++/ExerciseConfig.hpp +122 -0
- package/nitrogen/generated/shared/c++/ExercisePhase.hpp +88 -0
- package/nitrogen/generated/shared/c++/ExerciseType.hpp +80 -0
- package/nitrogen/generated/shared/c++/FormFeedback.hpp +93 -0
- package/nitrogen/generated/shared/c++/FormRule.hpp +105 -0
- package/nitrogen/generated/shared/c++/FormSeverity.hpp +80 -0
- package/nitrogen/generated/shared/c++/HoldProgress.hpp +91 -0
- package/nitrogen/generated/shared/c++/HybridNitroPoseExercisesSpec.cpp +46 -0
- package/nitrogen/generated/shared/c++/HybridNitroPoseExercisesSpec.hpp +117 -0
- package/nitrogen/generated/shared/c++/Landmark.hpp +95 -0
- package/nitrogen/generated/shared/c++/PhaseThreshold.hpp +97 -0
- package/nitrogen/generated/shared/c++/RepData.hpp +97 -0
- package/nitrogen/generated/shared/c++/SessionResult.hpp +108 -0
- package/nitrogen/generated/shared/c++/SessionStatus.hpp +88 -0
- package/package.json +187 -0
- package/src/NitroPoseExercises.nitro.ts +155 -0
- package/src/config/pushup.ts +62 -0
- package/src/index.tsx +28 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import MediaPipeTasksVision
|
|
4
|
+
import VisionCamera
|
|
5
|
+
import AVFoundation
|
|
6
|
+
|
|
7
|
+
class NitroPoseExercises: HybridNitroPoseExercisesSpec {
|
|
8
|
+
|
|
9
|
+
// ─── MediaPipe ──────────────────────────────────────────────
|
|
10
|
+
private var poseLandmarker: PoseLandmarker?
|
|
11
|
+
private var isInitialized = false
|
|
12
|
+
|
|
13
|
+
// ─── Exercise Config ────────────────────────────────────────
|
|
14
|
+
private var exerciseConfig: ExerciseConfig?
|
|
15
|
+
|
|
16
|
+
// ─── Session State ──────────────────────────────────────────
|
|
17
|
+
private var _status: SessionStatus = .idle
|
|
18
|
+
var status: SessionStatus { _status }
|
|
19
|
+
|
|
20
|
+
private var _currentPhase: ExercisePhase = .unknown
|
|
21
|
+
var currentPhase: ExercisePhase { _currentPhase }
|
|
22
|
+
|
|
23
|
+
private var _repCount: Double = 0
|
|
24
|
+
var repCount: Double { _repCount }
|
|
25
|
+
|
|
26
|
+
private var _landmarks: [Landmark] = []
|
|
27
|
+
var landmarks: [Landmark] { _landmarks }
|
|
28
|
+
|
|
29
|
+
// ─── State Machine ──────────────────────────────────────────
|
|
30
|
+
private var phaseHistory: [ExercisePhase] = []
|
|
31
|
+
private var repStartTime: Date = Date()
|
|
32
|
+
private var sessionStartTime: Date = Date()
|
|
33
|
+
private var targetReps: Double = 0
|
|
34
|
+
private var countdownSeconds: Double = 0
|
|
35
|
+
private var countdownTimer: Timer?
|
|
36
|
+
|
|
37
|
+
private var frameCount: Int = 0
|
|
38
|
+
private let processEveryNFrames: Int = 3 // Only process every 3rd frame
|
|
39
|
+
|
|
40
|
+
// ─── Form Tracking ──────────────────────────────────────────
|
|
41
|
+
private var lastFormFeedbackTime: [String: Date] = [:]
|
|
42
|
+
private var sessionFormViolations: [FormFeedback] = []
|
|
43
|
+
private var repFormScore: Double = 100.0
|
|
44
|
+
private var repAngleSnapshots: [AngleSnapshot] = []
|
|
45
|
+
private var allRepDurations: [Double] = []
|
|
46
|
+
private var allRepFormScores: [Double] = []
|
|
47
|
+
|
|
48
|
+
// ─── Pose Tracking ──────────────────────────────────────────
|
|
49
|
+
private var poseWasLost = false
|
|
50
|
+
|
|
51
|
+
// ─── Callbacks ──────────────────────────────────────────────
|
|
52
|
+
var onRepComplete: ((_ data: RepData) -> Void)?
|
|
53
|
+
var onPhaseChange: ((_ phase: ExercisePhase) -> Void)?
|
|
54
|
+
var onFormFeedback: ((_ feedback: FormFeedback) -> Void)?
|
|
55
|
+
var onHoldProgress: ((_ progress: HoldProgress) -> Void)?
|
|
56
|
+
var onPoseLost: (() -> Void)?
|
|
57
|
+
var onPoseRegained: (() -> Void)?
|
|
58
|
+
var onSessionComplete: ((_ result: SessionResult) -> Void)?
|
|
59
|
+
|
|
60
|
+
// ─── Hold Tracking ──────────────────────────────────────────
|
|
61
|
+
private var holdStartTime: Date?
|
|
62
|
+
|
|
63
|
+
// ═══════════════════════════════════════════════════════════
|
|
64
|
+
// MARK: - Lifecycle
|
|
65
|
+
// ═══════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
func initialize(modelPath: String) throws -> Promise<Void> {
|
|
68
|
+
print("[PoseExercise] initialize called with path: \(modelPath)")
|
|
69
|
+
|
|
70
|
+
return Promise.async { [weak self] in
|
|
71
|
+
guard let self = self else { return }
|
|
72
|
+
|
|
73
|
+
let options = PoseLandmarkerOptions()
|
|
74
|
+
options.baseOptions.modelAssetPath = modelPath
|
|
75
|
+
options.runningMode = .image
|
|
76
|
+
options.numPoses = 1
|
|
77
|
+
options.minPoseDetectionConfidence = 0.5
|
|
78
|
+
options.minPosePresenceConfidence = 0.5
|
|
79
|
+
options.minTrackingConfidence = 0.5
|
|
80
|
+
|
|
81
|
+
self.poseLandmarker = try PoseLandmarker(options: options)
|
|
82
|
+
self.isInitialized = true
|
|
83
|
+
|
|
84
|
+
print("[PoseExercise] MediaPipe initialized successfully")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func release() throws {
|
|
89
|
+
poseLandmarker = nil
|
|
90
|
+
isInitialized = false
|
|
91
|
+
_status = .idle
|
|
92
|
+
resetSession()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ═══════════════════════════════════════════════════════════
|
|
96
|
+
// MARK: - Exercise Setup
|
|
97
|
+
// ═══════════════════════════════════════════════════════════
|
|
98
|
+
|
|
99
|
+
func loadExercise(config: ExerciseConfig) throws {
|
|
100
|
+
self.exerciseConfig = config
|
|
101
|
+
resetSession()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ═══════════════════════════════════════════════════════════
|
|
105
|
+
// MARK: - Session Control
|
|
106
|
+
// ═══════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
func startSession(targetReps: Double, countdownSeconds: Double) throws {
|
|
109
|
+
print("[PoseExercise] startSession called - target: \(targetReps), countdown: \(countdownSeconds)")
|
|
110
|
+
|
|
111
|
+
resetSession()
|
|
112
|
+
self.targetReps = targetReps
|
|
113
|
+
self.countdownSeconds = countdownSeconds
|
|
114
|
+
|
|
115
|
+
if countdownSeconds > 0 {
|
|
116
|
+
_status = .countdown
|
|
117
|
+
startCountdown()
|
|
118
|
+
} else {
|
|
119
|
+
_status = .active
|
|
120
|
+
sessionStartTime = Date()
|
|
121
|
+
repStartTime = Date()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func pauseSession() throws {
|
|
126
|
+
guard _status == .active else { return }
|
|
127
|
+
_status = .paused
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func resumeSession() throws {
|
|
131
|
+
guard _status == .paused else { return }
|
|
132
|
+
_status = .active
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func stopSession() throws {
|
|
136
|
+
guard _status == .active || _status == .paused else { return }
|
|
137
|
+
completeSession()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ═══════════════════════════════════════════════════════════
|
|
141
|
+
// MARK: - Frame Processing
|
|
142
|
+
// ═══════════════════════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
func processFrame(frame: any HybridFrameSpec) throws {
|
|
145
|
+
print("[PoseExercise] processFrame called, status: \(_status)")
|
|
146
|
+
|
|
147
|
+
frameCount += 1
|
|
148
|
+
if frameCount % processEveryNFrames != 0 { return }
|
|
149
|
+
|
|
150
|
+
guard _status == .active || _status == .countdown else {
|
|
151
|
+
print("[PoseExercise] Skipping - status is \(_status)")
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
guard isInitialized, let landmarker = poseLandmarker else {
|
|
155
|
+
print("[PoseExercise] Skipping - not initialized: \(isInitialized)")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
guard let nativeFrame = frame as? any NativeFrame else {
|
|
160
|
+
print("[PoseExercise] Failed to cast to NativeFrame")
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
guard let sampleBuffer = nativeFrame.sampleBuffer else {
|
|
164
|
+
print("[PoseExercise] sampleBuffer is nil")
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
print("[PoseExercise] Got sampleBuffer, running detection...")
|
|
169
|
+
|
|
170
|
+
do {
|
|
171
|
+
let mpImage = try MPImage(sampleBuffer: sampleBuffer)
|
|
172
|
+
let result = try landmarker.detect(image: mpImage)
|
|
173
|
+
|
|
174
|
+
print("[PoseExercise] Detection done, landmarks count: \(result.landmarks.count)")
|
|
175
|
+
|
|
176
|
+
if let poseLandmarks = result.landmarks.first {
|
|
177
|
+
if poseWasLost {
|
|
178
|
+
poseWasLost = false
|
|
179
|
+
onPoseRegained?()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_landmarks = poseLandmarks.map { lm in
|
|
183
|
+
Landmark(
|
|
184
|
+
x: Double(lm.x),
|
|
185
|
+
y: Double(lm.y),
|
|
186
|
+
z: Double(lm.z),
|
|
187
|
+
visibility: Double(lm.visibility ?? 0)
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
print("[PoseExercise] Landmarks detected: \(_landmarks.count)")
|
|
192
|
+
|
|
193
|
+
if _status == .active {
|
|
194
|
+
processExerciseLogic()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
} else {
|
|
198
|
+
print("[PoseExercise] No pose detected in frame")
|
|
199
|
+
if !poseWasLost {
|
|
200
|
+
poseWasLost = true
|
|
201
|
+
onPoseLost?()
|
|
202
|
+
}
|
|
203
|
+
_landmarks = []
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
} catch {
|
|
207
|
+
print("[PoseExercise] MediaPipe error: \(error.localizedDescription)")
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ═══════════════════════════════════════════════════════════
|
|
212
|
+
// MARK: - Exercise Logic Engine
|
|
213
|
+
// ═══════════════════════════════════════════════════════════
|
|
214
|
+
|
|
215
|
+
private func processExerciseLogic() {
|
|
216
|
+
guard let config = exerciseConfig else { return }
|
|
217
|
+
guard !_landmarks.isEmpty else { return }
|
|
218
|
+
|
|
219
|
+
// 1. Calculate all angles defined in the config
|
|
220
|
+
var currentAngles: [String: Double] = [:]
|
|
221
|
+
var angleSnapshots: [AngleSnapshot] = []
|
|
222
|
+
|
|
223
|
+
for angleDef in config.angles {
|
|
224
|
+
let a = Int(angleDef.landmarkA)
|
|
225
|
+
let b = Int(angleDef.landmarkB)
|
|
226
|
+
let c = Int(angleDef.landmarkC)
|
|
227
|
+
|
|
228
|
+
guard a < _landmarks.count, b < _landmarks.count, c < _landmarks.count else { continue }
|
|
229
|
+
|
|
230
|
+
let angle = calculateAngle(
|
|
231
|
+
pointA: _landmarks[a],
|
|
232
|
+
vertex: _landmarks[b],
|
|
233
|
+
pointC: _landmarks[c]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
currentAngles[angleDef.name] = angle
|
|
237
|
+
angleSnapshots.append(AngleSnapshot(name: angleDef.name, value: angle))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
repAngleSnapshots = angleSnapshots
|
|
241
|
+
|
|
242
|
+
// 2. Determine current phase from angle thresholds
|
|
243
|
+
let detectedPhase = determinePhase(from: currentAngles, config: config)
|
|
244
|
+
|
|
245
|
+
if detectedPhase != _currentPhase && detectedPhase != .unknown {
|
|
246
|
+
let previousPhase = _currentPhase
|
|
247
|
+
_currentPhase = detectedPhase
|
|
248
|
+
onPhaseChange?(detectedPhase)
|
|
249
|
+
|
|
250
|
+
// 3. Update phase history for rep counting
|
|
251
|
+
handlePhaseTransition(from: previousPhase, to: detectedPhase, config: config)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 4. Check form rules
|
|
255
|
+
checkFormRules(currentAngles: currentAngles, config: config)
|
|
256
|
+
|
|
257
|
+
// 5. Handle hold-based exercises
|
|
258
|
+
if config.type == .hold {
|
|
259
|
+
handleHoldProgress(currentAngles: currentAngles, config: config)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Temporary debug logging — remove after testing
|
|
263
|
+
for (name, angle) in currentAngles {
|
|
264
|
+
print("[PoseExercise] Angle \(name): \(String(format: "%.1f", angle))°")
|
|
265
|
+
}
|
|
266
|
+
print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_currentPhase)")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ═══════════════════════════════════════════════════════════
|
|
270
|
+
// MARK: - Angle Calculation
|
|
271
|
+
// ═══════════════════════════════════════════════════════════
|
|
272
|
+
|
|
273
|
+
private func calculateAngle(pointA: Landmark, vertex: Landmark, pointC: Landmark) -> Double {
|
|
274
|
+
let vaX = pointA.x - vertex.x
|
|
275
|
+
let vaY = pointA.y - vertex.y
|
|
276
|
+
let vcX = pointC.x - vertex.x
|
|
277
|
+
let vcY = pointC.y - vertex.y
|
|
278
|
+
|
|
279
|
+
let dot = vaX * vcX + vaY * vcY
|
|
280
|
+
let magA = sqrt(vaX * vaX + vaY * vaY)
|
|
281
|
+
let magC = sqrt(vcX * vcX + vcY * vcY)
|
|
282
|
+
|
|
283
|
+
guard magA > 0, magC > 0 else { return 0 }
|
|
284
|
+
|
|
285
|
+
let cosAngle = max(-1.0, min(1.0, dot / (magA * magC)))
|
|
286
|
+
let angleRad = acos(cosAngle)
|
|
287
|
+
let angleDeg = angleRad * (180.0 / .pi)
|
|
288
|
+
|
|
289
|
+
return angleDeg
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ═══════════════════════════════════════════════════════════
|
|
293
|
+
// MARK: - Phase Detection
|
|
294
|
+
// ═══════════════════════════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
private func determinePhase(from angles: [String: Double], config: ExerciseConfig) -> ExercisePhase {
|
|
297
|
+
for phaseThreshold in config.phases {
|
|
298
|
+
guard let angle = angles[phaseThreshold.angleName] else { continue }
|
|
299
|
+
|
|
300
|
+
if angle >= phaseThreshold.minAngle && angle <= phaseThreshold.maxAngle {
|
|
301
|
+
return phaseThreshold.phase
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return .unknown
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ═══════════════════════════════════════════════════════════
|
|
308
|
+
// MARK: - Rep Counting State Machine
|
|
309
|
+
// ═══════════════════════════════════════════════════════════
|
|
310
|
+
|
|
311
|
+
private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhase: ExercisePhase, config: ExerciseConfig) {
|
|
312
|
+
guard config.type == .rep else { return }
|
|
313
|
+
|
|
314
|
+
phaseHistory.append(newPhase)
|
|
315
|
+
|
|
316
|
+
let repSeq = config.repSequence
|
|
317
|
+
if phaseHistory.count >= repSeq.count {
|
|
318
|
+
let tail = Array(phaseHistory.suffix(repSeq.count))
|
|
319
|
+
|
|
320
|
+
if tail == repSeq {
|
|
321
|
+
let now = Date()
|
|
322
|
+
let repDuration = now.timeIntervalSince(repStartTime) * 1000
|
|
323
|
+
|
|
324
|
+
// Minimum 800ms per rep — prevents false counts from noise
|
|
325
|
+
guard repDuration > 800 else {
|
|
326
|
+
phaseHistory = [newPhase]
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Don't count rep if form is terrible
|
|
331
|
+
guard repFormScore > 30 else {
|
|
332
|
+
onFormFeedback?(FormFeedback(
|
|
333
|
+
ruleName: "poorForm",
|
|
334
|
+
message: "Fix your form before continuing",
|
|
335
|
+
severity: .error
|
|
336
|
+
))
|
|
337
|
+
repFormScore = 100.0
|
|
338
|
+
phaseHistory = [newPhase]
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_repCount += 1
|
|
343
|
+
|
|
344
|
+
let repData = RepData(
|
|
345
|
+
repNumber: _repCount,
|
|
346
|
+
durationMs: repDuration,
|
|
347
|
+
formScore: repFormScore,
|
|
348
|
+
angles: repAngleSnapshots
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
allRepDurations.append(repDuration)
|
|
352
|
+
allRepFormScores.append(repFormScore)
|
|
353
|
+
|
|
354
|
+
onRepComplete?(repData)
|
|
355
|
+
|
|
356
|
+
repStartTime = now
|
|
357
|
+
repFormScore = 100.0
|
|
358
|
+
phaseHistory = [newPhase]
|
|
359
|
+
|
|
360
|
+
if targetReps > 0 && _repCount >= targetReps {
|
|
361
|
+
completeSession()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let maxHistory = config.repSequence.count * 2
|
|
367
|
+
if phaseHistory.count > maxHistory {
|
|
368
|
+
phaseHistory = Array(phaseHistory.suffix(maxHistory))
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ═══════════════════════════════════════════════════════════
|
|
373
|
+
// MARK: - Form Validation
|
|
374
|
+
// ═══════════════════════════════════════════════════════════
|
|
375
|
+
|
|
376
|
+
private func checkFormRules(currentAngles: [String: Double], config: ExerciseConfig) {
|
|
377
|
+
let now = Date()
|
|
378
|
+
let throttleInterval: TimeInterval = 3.0
|
|
379
|
+
|
|
380
|
+
for rule in config.formRules {
|
|
381
|
+
guard let angle = currentAngles[rule.angleName] else { continue }
|
|
382
|
+
|
|
383
|
+
let isViolating = angle < rule.minAngle || angle > rule.maxAngle
|
|
384
|
+
|
|
385
|
+
if isViolating {
|
|
386
|
+
if let lastTime = lastFormFeedbackTime[rule.name],
|
|
387
|
+
now.timeIntervalSince(lastTime) < throttleInterval {
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let feedback = FormFeedback(
|
|
392
|
+
ruleName: rule.name,
|
|
393
|
+
message: rule.message,
|
|
394
|
+
severity: rule.severity
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
switch rule.severity {
|
|
398
|
+
case .warning:
|
|
399
|
+
repFormScore = max(0, repFormScore - 5)
|
|
400
|
+
case .error:
|
|
401
|
+
repFormScore = max(0, repFormScore - 15)
|
|
402
|
+
case .info:
|
|
403
|
+
break
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
sessionFormViolations.append(feedback)
|
|
407
|
+
lastFormFeedbackTime[rule.name] = now
|
|
408
|
+
onFormFeedback?(feedback)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ═══════════════════════════════════════════════════════════
|
|
414
|
+
// MARK: - Hold Progress
|
|
415
|
+
// ═══════════════════════════════════════════════════════════
|
|
416
|
+
|
|
417
|
+
private func handleHoldProgress(currentAngles: [String: Double], config: ExerciseConfig) {
|
|
418
|
+
guard config.holdDurationMs > 0 else { return }
|
|
419
|
+
|
|
420
|
+
var inPosition = true
|
|
421
|
+
for phaseThreshold in config.phases {
|
|
422
|
+
guard let angle = currentAngles[phaseThreshold.angleName] else {
|
|
423
|
+
inPosition = false
|
|
424
|
+
break
|
|
425
|
+
}
|
|
426
|
+
if angle < phaseThreshold.minAngle || angle > phaseThreshold.maxAngle {
|
|
427
|
+
inPosition = false
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if inPosition {
|
|
433
|
+
if holdStartTime == nil {
|
|
434
|
+
holdStartTime = Date()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let elapsed = Date().timeIntervalSince(holdStartTime!) * 1000
|
|
438
|
+
let stability = min(100.0, max(0.0, repFormScore))
|
|
439
|
+
|
|
440
|
+
let progress = HoldProgress(
|
|
441
|
+
elapsedMs: elapsed,
|
|
442
|
+
targetMs: config.holdDurationMs,
|
|
443
|
+
stability: stability
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
onHoldProgress?(progress)
|
|
447
|
+
|
|
448
|
+
if elapsed >= config.holdDurationMs {
|
|
449
|
+
completeSession()
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
holdStartTime = nil
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ═══════════════════════════════════════════════════════════
|
|
457
|
+
// MARK: - Session Completion
|
|
458
|
+
// ═══════════════════════════════════════════════════════════
|
|
459
|
+
|
|
460
|
+
private func completeSession() {
|
|
461
|
+
_status = .completed
|
|
462
|
+
|
|
463
|
+
let totalDuration = Date().timeIntervalSince(sessionStartTime) * 1000
|
|
464
|
+
let avgRepDuration = allRepDurations.isEmpty ? 0 : allRepDurations.reduce(0, +) / Double(allRepDurations.count)
|
|
465
|
+
let avgFormScore = allRepFormScores.isEmpty ? 100.0 : allRepFormScores.reduce(0, +) / Double(allRepFormScores.count)
|
|
466
|
+
|
|
467
|
+
let result = SessionResult(
|
|
468
|
+
totalReps: _repCount,
|
|
469
|
+
totalDurationMs: totalDuration,
|
|
470
|
+
averageRepDurationMs: avgRepDuration,
|
|
471
|
+
averageFormScore: avgFormScore,
|
|
472
|
+
formViolations: sessionFormViolations,
|
|
473
|
+
angleHistory: repAngleSnapshots
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
onSessionComplete?(result)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ═══════════════════════════════════════════════════════════
|
|
480
|
+
// MARK: - Countdown
|
|
481
|
+
// ═══════════════════════════════════════════════════════════
|
|
482
|
+
|
|
483
|
+
private func startCountdown() {
|
|
484
|
+
var remaining = countdownSeconds
|
|
485
|
+
|
|
486
|
+
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
|
|
487
|
+
guard let self = self else {
|
|
488
|
+
timer.invalidate()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
remaining -= 1
|
|
493
|
+
|
|
494
|
+
if remaining <= 0 {
|
|
495
|
+
timer.invalidate()
|
|
496
|
+
self.countdownTimer = nil
|
|
497
|
+
self._status = .active
|
|
498
|
+
self.sessionStartTime = Date()
|
|
499
|
+
self.repStartTime = Date()
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ═══════════════════════════════════════════════════════════
|
|
505
|
+
// MARK: - Reset
|
|
506
|
+
// ═══════════════════════════════════════════════════════════
|
|
507
|
+
|
|
508
|
+
private func resetSession() {
|
|
509
|
+
_status = .idle
|
|
510
|
+
_currentPhase = .unknown
|
|
511
|
+
_repCount = 0
|
|
512
|
+
_landmarks = []
|
|
513
|
+
phaseHistory = []
|
|
514
|
+
repFormScore = 100.0
|
|
515
|
+
repAngleSnapshots = []
|
|
516
|
+
allRepDurations = []
|
|
517
|
+
allRepFormScores = []
|
|
518
|
+
sessionFormViolations = []
|
|
519
|
+
lastFormFeedbackTime = [:]
|
|
520
|
+
holdStartTime = nil
|
|
521
|
+
poseWasLost = false
|
|
522
|
+
targetReps = 0
|
|
523
|
+
countdownSeconds = 0
|
|
524
|
+
countdownTimer?.invalidate()
|
|
525
|
+
countdownTimer = nil
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { NitroModules } from 'react-native-nitro-modules';
|
|
4
|
+
|
|
5
|
+
// ─── Enums & Types ───────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
// ─── Landmark ────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
// ─── Exercise Config (passed from JS) ────────────────────────
|
|
10
|
+
|
|
11
|
+
// ─── Callback Payloads ───────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
// ─── The HybridObject ────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const nitroPoseExercises = NitroModules.createHybridObject('PoseExercise');
|
|
16
|
+
export { nitroPoseExercises };
|
|
17
|
+
//# sourceMappingURL=NitroPoseExercises.nitro.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["NitroModules","nitroPoseExercises","createHybridObject"],"sourceRoot":"../../src","sources":["NitroPoseExercises.nitro.ts"],"mappings":";;AAAA,SAA4BA,YAAY,QAAQ,4BAA4B;;AAI5E;;AAUA;;AASA;;AAmCA;;AAmCA;;AAwCA,MAAMC,kBAAkB,GACtBD,YAAY,CAACE,kBAAkB,CAAqB,cAAc,CAAC;AAErE,SAASD,kBAAkB","ignoreList":[]}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// MediaPipe Pose Landmark indices
|
|
4
|
+
// 11 = left shoulder, 13 = left elbow, 15 = left wrist
|
|
5
|
+
// 12 = right shoulder, 14 = right elbow, 16 = right wrist
|
|
6
|
+
// 23 = left hip, 25 = left knee, 27 = left ankle
|
|
7
|
+
// 24 = right hip, 26 = right knee, 28 = right ankle
|
|
8
|
+
|
|
9
|
+
export const PUSHUP_CONFIG = {
|
|
10
|
+
name: 'Push-Up',
|
|
11
|
+
type: 'rep',
|
|
12
|
+
angles: [{
|
|
13
|
+
name: 'leftElbow',
|
|
14
|
+
landmarkA: 11,
|
|
15
|
+
// left shoulder
|
|
16
|
+
landmarkB: 13,
|
|
17
|
+
// left elbow (vertex)
|
|
18
|
+
landmarkC: 15 // left wrist
|
|
19
|
+
}, {
|
|
20
|
+
name: 'rightElbow',
|
|
21
|
+
landmarkA: 12,
|
|
22
|
+
// right shoulder
|
|
23
|
+
landmarkB: 14,
|
|
24
|
+
// right elbow (vertex)
|
|
25
|
+
landmarkC: 16 // right wrist
|
|
26
|
+
}, {
|
|
27
|
+
name: 'leftHip',
|
|
28
|
+
landmarkA: 11,
|
|
29
|
+
// left shoulder
|
|
30
|
+
landmarkB: 23,
|
|
31
|
+
// left hip (vertex)
|
|
32
|
+
landmarkC: 27 // left ankle
|
|
33
|
+
}, {
|
|
34
|
+
name: 'rightHip',
|
|
35
|
+
landmarkA: 12,
|
|
36
|
+
// right shoulder
|
|
37
|
+
landmarkB: 24,
|
|
38
|
+
// right hip (vertex)
|
|
39
|
+
landmarkC: 28 // right ankle
|
|
40
|
+
}],
|
|
41
|
+
phases: [{
|
|
42
|
+
phase: 'up',
|
|
43
|
+
angleName: 'leftElbow',
|
|
44
|
+
minAngle: 150,
|
|
45
|
+
maxAngle: 180
|
|
46
|
+
}, {
|
|
47
|
+
phase: 'down',
|
|
48
|
+
angleName: 'leftElbow',
|
|
49
|
+
minAngle: 30,
|
|
50
|
+
maxAngle: 90
|
|
51
|
+
}],
|
|
52
|
+
repSequence: ['up', 'down', 'up'],
|
|
53
|
+
formRules: [{
|
|
54
|
+
name: 'hipSag',
|
|
55
|
+
message: 'Keep your hips up — your body should be a straight line',
|
|
56
|
+
severity: 'warning',
|
|
57
|
+
angleName: 'leftHip',
|
|
58
|
+
minAngle: 160,
|
|
59
|
+
// body should be mostly straight
|
|
60
|
+
maxAngle: 180
|
|
61
|
+
}, {
|
|
62
|
+
name: 'hipPike',
|
|
63
|
+
message: "Lower your hips — you're piking up",
|
|
64
|
+
severity: 'warning',
|
|
65
|
+
angleName: 'leftHip',
|
|
66
|
+
minAngle: 160,
|
|
67
|
+
maxAngle: 180
|
|
68
|
+
}],
|
|
69
|
+
holdDurationMs: 0 // not a hold exercise
|
|
70
|
+
};
|
|
71
|
+
//# sourceMappingURL=pushup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["PUSHUP_CONFIG","name","type","angles","landmarkA","landmarkB","landmarkC","phases","phase","angleName","minAngle","maxAngle","repSequence","formRules","message","severity","holdDurationMs"],"sourceRoot":"../../../src","sources":["config/pushup.ts"],"mappings":";;AAEA;AACA;AACA;AACA;AACA;;AAEA,OAAO,MAAMA,aAA6B,GAAG;EAC3CC,IAAI,EAAE,SAAS;EACfC,IAAI,EAAE,KAAK;EACXC,MAAM,EAAE,CACN;IACEF,IAAI,EAAE,WAAW;IACjBG,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE,CAAE;EACjB,CAAC,EACD;IACEL,IAAI,EAAE,YAAY;IAClBG,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE,CAAE;EACjB,CAAC,EACD;IACEL,IAAI,EAAE,SAAS;IACfG,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE,CAAE;EACjB,CAAC,EACD;IACEL,IAAI,EAAE,UAAU;IAChBG,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE;IAAE;IACfC,SAAS,EAAE,EAAE,CAAE;EACjB,CAAC,CACF;EACDC,MAAM,EAAE,CACN;IAAEC,KAAK,EAAE,IAAI;IAAEC,SAAS,EAAE,WAAW;IAAEC,QAAQ,EAAE,GAAG;IAAEC,QAAQ,EAAE;EAAI,CAAC,EACrE;IAAEH,KAAK,EAAE,MAAM;IAAEC,SAAS,EAAE,WAAW;IAAEC,QAAQ,EAAE,EAAE;IAAEC,QAAQ,EAAE;EAAG,CAAC,CACtE;EACDC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC;EACjCC,SAAS,EAAE,CACT;IACEZ,IAAI,EAAE,QAAQ;IACda,OAAO,EAAE,yDAAyD;IAClEC,QAAQ,EAAE,SAAS;IACnBN,SAAS,EAAE,SAAS;IACpBC,QAAQ,EAAE,GAAG;IAAE;IACfC,QAAQ,EAAE;EACZ,CAAC,EACD;IACEV,IAAI,EAAE,SAAS;IACfa,OAAO,EAAE,oCAAoC;IAC7CC,QAAQ,EAAE,SAAS;IACnBN,SAAS,EAAE,SAAS;IACpBC,QAAQ,EAAE,GAAG;IACbC,QAAQ,EAAE;EACZ,CAAC,CACF;EACDK,cAAc,EAAE,CAAC,CAAE;AACrB,CAAC","ignoreList":[]}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { NitroModules } from 'react-native-nitro-modules';
|
|
4
|
+
const nitroPoseExercises = NitroModules.createHybridObject('NitroPoseExercises');
|
|
5
|
+
export { nitroPoseExercises };
|
|
6
|
+
export { PUSHUP_CONFIG } from "./config/pushup.js";
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["NitroModules","nitroPoseExercises","createHybridObject","PUSHUP_CONFIG"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,YAAY,QAAQ,4BAA4B;AAIzD,MAAMC,kBAAkB,GACtBD,YAAY,CAACE,kBAAkB,CAAqB,oBAAoB,CAAC;AAE3E,SAASD,kBAAkB;AAoB3B,SAASE,aAAa,QAAQ,oBAAiB","ignoreList":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|