react-native-workouts 0.1.0
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/.eslintrc.js +5 -0
- package/README.md +343 -0
- package/build/ReactNativeWorkouts.types.d.ts +124 -0
- package/build/ReactNativeWorkouts.types.d.ts.map +1 -0
- package/build/ReactNativeWorkouts.types.js +3 -0
- package/build/ReactNativeWorkouts.types.js.map +1 -0
- package/build/ReactNativeWorkoutsModule.d.ts +23 -0
- package/build/ReactNativeWorkoutsModule.d.ts.map +1 -0
- package/build/ReactNativeWorkoutsModule.js +3 -0
- package/build/ReactNativeWorkoutsModule.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/ios/ReactNativeWorkouts.podspec +29 -0
- package/ios/ReactNativeWorkoutsModule.swift +657 -0
- package/package.json +61 -0
- package/src/ReactNativeWorkouts.types.ts +242 -0
- package/src/ReactNativeWorkoutsModule.ts +72 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import WorkoutKit
|
|
3
|
+
import HealthKit
|
|
4
|
+
|
|
5
|
+
public class ReactNativeWorkoutsModule: Module {
|
|
6
|
+
private let healthStore = HKHealthStore()
|
|
7
|
+
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
Name("ReactNativeWorkouts")
|
|
10
|
+
|
|
11
|
+
// MARK: - Constants
|
|
12
|
+
|
|
13
|
+
Constants([
|
|
14
|
+
"isAvailable": HKHealthStore.isHealthDataAvailable()
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
// MARK: - Events
|
|
18
|
+
|
|
19
|
+
Events("onAuthorizationChange")
|
|
20
|
+
|
|
21
|
+
// MARK: - Authorization
|
|
22
|
+
|
|
23
|
+
AsyncFunction("getAuthorizationStatus") { () -> String in
|
|
24
|
+
let status = await WorkoutScheduler.shared.authorizationState
|
|
25
|
+
return self.authorizationStateToString(status)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
AsyncFunction("requestAuthorization") { () -> String in
|
|
29
|
+
let status = await WorkoutScheduler.shared.requestAuthorization()
|
|
30
|
+
return self.authorizationStateToString(status)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// MARK: - Workout Validation
|
|
34
|
+
|
|
35
|
+
AsyncFunction("supportsGoal") { (activityType: String, locationType: String, goalType: String) -> Bool in
|
|
36
|
+
guard let activity = self.parseActivityType(activityType),
|
|
37
|
+
let location = self.parseLocationType(locationType),
|
|
38
|
+
let goal = self.parseGoalTypeForValidation(goalType) else {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return CustomWorkout.supports(goal: goal, activity: activity, location: location)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// MARK: - Custom Workout Creation
|
|
46
|
+
|
|
47
|
+
AsyncFunction("createCustomWorkout") { (config: [String: Any]) throws -> [String: Any] in
|
|
48
|
+
let workout = try self.buildCustomWorkout(from: config)
|
|
49
|
+
let composition = CustomWorkoutComposition(workout)
|
|
50
|
+
|
|
51
|
+
do {
|
|
52
|
+
try await composition.validate()
|
|
53
|
+
return [
|
|
54
|
+
"valid": true,
|
|
55
|
+
"displayName": workout.displayName ?? ""
|
|
56
|
+
]
|
|
57
|
+
} catch {
|
|
58
|
+
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Scheduled Workouts
|
|
63
|
+
|
|
64
|
+
AsyncFunction("scheduleWorkout") { (config: [String: Any], date: [String: Any]) throws -> [String: Any] in
|
|
65
|
+
let workout = try self.buildCustomWorkout(from: config)
|
|
66
|
+
let composition = CustomWorkoutComposition(workout)
|
|
67
|
+
|
|
68
|
+
do {
|
|
69
|
+
try await composition.validate()
|
|
70
|
+
} catch {
|
|
71
|
+
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let dateComponents = self.parseDateComponents(from: date)
|
|
75
|
+
let plan = ScheduledWorkoutPlan(workout: composition, date: dateComponents)
|
|
76
|
+
|
|
77
|
+
do {
|
|
78
|
+
try await WorkoutScheduler.shared.schedule(plan)
|
|
79
|
+
return [
|
|
80
|
+
"success": true,
|
|
81
|
+
"id": plan.id.uuidString
|
|
82
|
+
]
|
|
83
|
+
} catch {
|
|
84
|
+
throw Exception(name: "ScheduleError", description: error.localizedDescription)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
AsyncFunction("getScheduledWorkouts") { () throws -> [[String: Any]] in
|
|
89
|
+
let workouts = await WorkoutScheduler.shared.scheduledWorkouts
|
|
90
|
+
return workouts.map { plan in
|
|
91
|
+
return [
|
|
92
|
+
"id": plan.id.uuidString,
|
|
93
|
+
"date": self.dateComponentsToDict(plan.date)
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
AsyncFunction("removeScheduledWorkout") { (id: String) throws -> Bool in
|
|
99
|
+
guard let uuid = UUID(uuidString: id) else {
|
|
100
|
+
throw Exception(name: "InvalidID", description: "Invalid workout ID format")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let workouts = await WorkoutScheduler.shared.scheduledWorkouts
|
|
104
|
+
guard let workout = workouts.first(where: { $0.id == uuid }) else {
|
|
105
|
+
throw Exception(name: "NotFound", description: "Workout not found")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
do {
|
|
109
|
+
try await WorkoutScheduler.shared.remove(workout)
|
|
110
|
+
return true
|
|
111
|
+
} catch {
|
|
112
|
+
throw Exception(name: "RemoveError", description: error.localizedDescription)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
AsyncFunction("removeAllScheduledWorkouts") { () throws -> Bool in
|
|
117
|
+
let workouts = await WorkoutScheduler.shared.scheduledWorkouts
|
|
118
|
+
|
|
119
|
+
for workout in workouts {
|
|
120
|
+
do {
|
|
121
|
+
try await WorkoutScheduler.shared.remove(workout)
|
|
122
|
+
} catch {
|
|
123
|
+
throw Exception(name: "RemoveError", description: error.localizedDescription)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// MARK: - Single Goal Workout
|
|
131
|
+
|
|
132
|
+
AsyncFunction("createSingleGoalWorkout") { (config: [String: Any]) throws -> [String: Any] in
|
|
133
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
134
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
135
|
+
let goalConfig = config["goal"] as? [String: Any] else {
|
|
136
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, goal")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
140
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
141
|
+
let displayName = config["displayName"] as? String
|
|
142
|
+
|
|
143
|
+
let workout = SingleGoalWorkout(activity: activity, location: location, goal: goal, displayName: displayName)
|
|
144
|
+
let composition = SingleGoalWorkoutComposition(workout)
|
|
145
|
+
|
|
146
|
+
do {
|
|
147
|
+
try await composition.validate()
|
|
148
|
+
return [
|
|
149
|
+
"valid": true,
|
|
150
|
+
"displayName": workout.displayName ?? ""
|
|
151
|
+
]
|
|
152
|
+
} catch {
|
|
153
|
+
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
AsyncFunction("scheduleSingleGoalWorkout") { (config: [String: Any], date: [String: Any]) throws -> [String: Any] in
|
|
158
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
159
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
160
|
+
let goalConfig = config["goal"] as? [String: Any] else {
|
|
161
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, goal")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
165
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
166
|
+
let displayName = config["displayName"] as? String
|
|
167
|
+
|
|
168
|
+
let workout = SingleGoalWorkout(activity: activity, location: location, goal: goal, displayName: displayName)
|
|
169
|
+
let composition = SingleGoalWorkoutComposition(workout)
|
|
170
|
+
|
|
171
|
+
do {
|
|
172
|
+
try await composition.validate()
|
|
173
|
+
} catch {
|
|
174
|
+
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let dateComponents = self.parseDateComponents(from: date)
|
|
178
|
+
let plan = ScheduledWorkoutPlan(workout: composition, date: dateComponents)
|
|
179
|
+
|
|
180
|
+
do {
|
|
181
|
+
try await WorkoutScheduler.shared.schedule(plan)
|
|
182
|
+
return [
|
|
183
|
+
"success": true,
|
|
184
|
+
"id": plan.id.uuidString
|
|
185
|
+
]
|
|
186
|
+
} catch {
|
|
187
|
+
throw Exception(name: "ScheduleError", description: error.localizedDescription)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// MARK: - Pacer Workout
|
|
192
|
+
|
|
193
|
+
AsyncFunction("createPacerWorkout") { (config: [String: Any]) throws -> [String: Any] in
|
|
194
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
195
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
196
|
+
let targetConfig = config["target"] as? [String: Any] else {
|
|
197
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, target")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let target = try self.parsePacerTarget(from: targetConfig)
|
|
201
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
202
|
+
let displayName = config["displayName"] as? String
|
|
203
|
+
|
|
204
|
+
let workout = PacerWorkout(activity: activity, location: location, target: target, displayName: displayName)
|
|
205
|
+
let composition = PacerWorkoutComposition(workout)
|
|
206
|
+
|
|
207
|
+
do {
|
|
208
|
+
try await composition.validate()
|
|
209
|
+
return [
|
|
210
|
+
"valid": true,
|
|
211
|
+
"displayName": workout.displayName ?? ""
|
|
212
|
+
]
|
|
213
|
+
} catch {
|
|
214
|
+
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
AsyncFunction("schedulePacerWorkout") { (config: [String: Any], date: [String: Any]) throws -> [String: Any] in
|
|
219
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
220
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
221
|
+
let targetConfig = config["target"] as? [String: Any] else {
|
|
222
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, target")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let target = try self.parsePacerTarget(from: targetConfig)
|
|
226
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
227
|
+
let displayName = config["displayName"] as? String
|
|
228
|
+
|
|
229
|
+
let workout = PacerWorkout(activity: activity, location: location, target: target, displayName: displayName)
|
|
230
|
+
let composition = PacerWorkoutComposition(workout)
|
|
231
|
+
|
|
232
|
+
do {
|
|
233
|
+
try await composition.validate()
|
|
234
|
+
} catch {
|
|
235
|
+
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let dateComponents = self.parseDateComponents(from: date)
|
|
239
|
+
let plan = ScheduledWorkoutPlan(workout: composition, date: dateComponents)
|
|
240
|
+
|
|
241
|
+
do {
|
|
242
|
+
try await WorkoutScheduler.shared.schedule(plan)
|
|
243
|
+
return [
|
|
244
|
+
"success": true,
|
|
245
|
+
"id": plan.id.uuidString
|
|
246
|
+
]
|
|
247
|
+
} catch {
|
|
248
|
+
throw Exception(name: "ScheduleError", description: error.localizedDescription)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// MARK: - Activity Types
|
|
253
|
+
|
|
254
|
+
Function("getSupportedActivityTypes") { () -> [String] in
|
|
255
|
+
return [
|
|
256
|
+
"running",
|
|
257
|
+
"cycling",
|
|
258
|
+
"walking",
|
|
259
|
+
"hiking",
|
|
260
|
+
"swimming",
|
|
261
|
+
"rowing",
|
|
262
|
+
"elliptical",
|
|
263
|
+
"stairClimbing",
|
|
264
|
+
"highIntensityIntervalTraining",
|
|
265
|
+
"yoga",
|
|
266
|
+
"functionalStrengthTraining",
|
|
267
|
+
"traditionalStrengthTraining",
|
|
268
|
+
"dance",
|
|
269
|
+
"jumpRope",
|
|
270
|
+
"coreTraining",
|
|
271
|
+
"pilates",
|
|
272
|
+
"kickboxing",
|
|
273
|
+
"stairs",
|
|
274
|
+
"stepTraining",
|
|
275
|
+
"wheelchairRunPace",
|
|
276
|
+
"wheelchairWalkPace"
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
Function("getSupportedGoalTypes") { () -> [String] in
|
|
281
|
+
return [
|
|
282
|
+
"open",
|
|
283
|
+
"distance",
|
|
284
|
+
"time",
|
|
285
|
+
"energy"
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
Function("getSupportedLocationTypes") { () -> [String] in
|
|
290
|
+
return [
|
|
291
|
+
"indoor",
|
|
292
|
+
"outdoor"
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// MARK: - Helper Methods
|
|
298
|
+
|
|
299
|
+
private func authorizationStateToString(_ state: WorkoutScheduler.AuthorizationState) -> String {
|
|
300
|
+
switch state {
|
|
301
|
+
case .authorized:
|
|
302
|
+
return "authorized"
|
|
303
|
+
case .notDetermined:
|
|
304
|
+
return "notDetermined"
|
|
305
|
+
case .denied:
|
|
306
|
+
return "denied"
|
|
307
|
+
@unknown default:
|
|
308
|
+
return "unknown"
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private func parseActivityType(_ type: String) -> HKWorkoutActivityType? {
|
|
313
|
+
switch type.lowercased() {
|
|
314
|
+
case "running": return .running
|
|
315
|
+
case "cycling": return .cycling
|
|
316
|
+
case "walking": return .walking
|
|
317
|
+
case "hiking": return .hiking
|
|
318
|
+
case "swimming": return .swimming
|
|
319
|
+
case "rowing": return .rowing
|
|
320
|
+
case "elliptical": return .elliptical
|
|
321
|
+
case "stairclimbing": return .stairClimbing
|
|
322
|
+
case "highintensityintervaltraining", "hiit": return .highIntensityIntervalTraining
|
|
323
|
+
case "yoga": return .yoga
|
|
324
|
+
case "functionalstrengthtraining": return .functionalStrengthTraining
|
|
325
|
+
case "traditionalstrengthtraining": return .traditionalStrengthTraining
|
|
326
|
+
case "dance": return .dance
|
|
327
|
+
case "jumprope": return .jumpRope
|
|
328
|
+
case "coretraining": return .coreTraining
|
|
329
|
+
case "pilates": return .pilates
|
|
330
|
+
case "kickboxing": return .kickboxing
|
|
331
|
+
case "stairs": return .stairs
|
|
332
|
+
case "steptraining": return .stepTraining
|
|
333
|
+
case "wheelchairrunpace": return .wheelchairRunPace
|
|
334
|
+
case "wheelchairwalkpace": return .wheelchairWalkPace
|
|
335
|
+
default: return nil
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private func parseLocationType(_ type: String?) -> WorkoutLocationType? {
|
|
340
|
+
guard let type = type else { return .outdoor }
|
|
341
|
+
switch type.lowercased() {
|
|
342
|
+
case "indoor": return .indoor
|
|
343
|
+
case "outdoor": return .outdoor
|
|
344
|
+
default: return .outdoor
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private func parseGoalTypeForValidation(_ type: String) -> WorkoutGoal? {
|
|
349
|
+
switch type.lowercased() {
|
|
350
|
+
case "open": return .open
|
|
351
|
+
case "distance": return .distance(1, .meters)
|
|
352
|
+
case "time": return .time(1, .seconds)
|
|
353
|
+
case "energy": return .energy(1, .kilocalories)
|
|
354
|
+
default: return nil
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private func parseWorkoutGoal(from config: [String: Any]) throws -> WorkoutGoal {
|
|
359
|
+
guard let type = config["type"] as? String else {
|
|
360
|
+
throw Exception(name: "InvalidGoal", description: "Goal type is required")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
switch type.lowercased() {
|
|
364
|
+
case "open":
|
|
365
|
+
return .open
|
|
366
|
+
|
|
367
|
+
case "distance":
|
|
368
|
+
guard let value = config["value"] as? Double else {
|
|
369
|
+
throw Exception(name: "InvalidGoal", description: "Distance value is required")
|
|
370
|
+
}
|
|
371
|
+
let unitStr = config["unit"] as? String ?? "meters"
|
|
372
|
+
let unit = self.parseDistanceUnit(unitStr)
|
|
373
|
+
return .distance(value, unit)
|
|
374
|
+
|
|
375
|
+
case "time":
|
|
376
|
+
guard let value = config["value"] as? Double else {
|
|
377
|
+
throw Exception(name: "InvalidGoal", description: "Time value is required")
|
|
378
|
+
}
|
|
379
|
+
let unitStr = config["unit"] as? String ?? "seconds"
|
|
380
|
+
let unit = self.parseTimeUnit(unitStr)
|
|
381
|
+
return .time(value, unit)
|
|
382
|
+
|
|
383
|
+
case "energy":
|
|
384
|
+
guard let value = config["value"] as? Double else {
|
|
385
|
+
throw Exception(name: "InvalidGoal", description: "Energy value is required")
|
|
386
|
+
}
|
|
387
|
+
let unitStr = config["unit"] as? String ?? "kilocalories"
|
|
388
|
+
let unit = self.parseEnergyUnit(unitStr)
|
|
389
|
+
return .energy(value, unit)
|
|
390
|
+
|
|
391
|
+
default:
|
|
392
|
+
throw Exception(name: "InvalidGoal", description: "Unknown goal type: \(type)")
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private func parseDistanceUnit(_ unit: String) -> UnitLength {
|
|
397
|
+
switch unit.lowercased() {
|
|
398
|
+
case "meters", "m": return .meters
|
|
399
|
+
case "kilometers", "km": return .kilometers
|
|
400
|
+
case "miles", "mi": return .miles
|
|
401
|
+
case "yards", "yd": return .yards
|
|
402
|
+
case "feet", "ft": return .feet
|
|
403
|
+
default: return .meters
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private func parseTimeUnit(_ unit: String) -> UnitDuration {
|
|
408
|
+
switch unit.lowercased() {
|
|
409
|
+
case "seconds", "s", "sec": return .seconds
|
|
410
|
+
case "minutes", "min": return .minutes
|
|
411
|
+
case "hours", "h", "hr": return .hours
|
|
412
|
+
default: return .seconds
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private func parseEnergyUnit(_ unit: String) -> UnitEnergy {
|
|
417
|
+
switch unit.lowercased() {
|
|
418
|
+
case "kilocalories", "kcal", "cal": return .kilocalories
|
|
419
|
+
case "kilojoules", "kj": return .kilojoules
|
|
420
|
+
default: return .kilocalories
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private func parsePacerTarget(from config: [String: Any]) throws -> PacerWorkout.Target {
|
|
425
|
+
guard let type = config["type"] as? String else {
|
|
426
|
+
throw Exception(name: "InvalidTarget", description: "Target type is required")
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
guard let value = config["value"] as? Double else {
|
|
430
|
+
throw Exception(name: "InvalidTarget", description: "Target value is required")
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
switch type.lowercased() {
|
|
434
|
+
case "speed":
|
|
435
|
+
let unitStr = config["unit"] as? String ?? "metersPerSecond"
|
|
436
|
+
let unit = self.parseSpeedUnit(unitStr)
|
|
437
|
+
return .speed(value, unit: unit)
|
|
438
|
+
|
|
439
|
+
case "pace":
|
|
440
|
+
let unitStr = config["unit"] as? String ?? "minutesPerKilometer"
|
|
441
|
+
let unit = self.parsePaceUnit(unitStr)
|
|
442
|
+
return .pace(time: value, unit: unit)
|
|
443
|
+
|
|
444
|
+
default:
|
|
445
|
+
throw Exception(name: "InvalidTarget", description: "Unknown target type: \(type)")
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private func parseSpeedUnit(_ unit: String) -> UnitSpeed {
|
|
450
|
+
switch unit.lowercased() {
|
|
451
|
+
case "meterspersecond", "mps", "m/s": return .metersPerSecond
|
|
452
|
+
case "kilometersperhour", "kph", "km/h": return .kilometersPerHour
|
|
453
|
+
case "milesperhour", "mph": return .milesPerHour
|
|
454
|
+
default: return .metersPerSecond
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private func parsePaceUnit(_ unit: String) -> UnitLength {
|
|
459
|
+
switch unit.lowercased() {
|
|
460
|
+
case "minutesperkilometer", "min/km": return .kilometers
|
|
461
|
+
case "minutespermile", "min/mi": return .miles
|
|
462
|
+
default: return .kilometers
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private func parseStepPurpose(_ purpose: String) -> IntervalStep.Purpose {
|
|
467
|
+
switch purpose.lowercased() {
|
|
468
|
+
case "work": return .work
|
|
469
|
+
case "recovery": return .recovery
|
|
470
|
+
default: return .work
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private func parseWorkoutAlert(from config: [String: Any]) throws -> WorkoutAlert? {
|
|
475
|
+
guard let type = config["type"] as? String else {
|
|
476
|
+
return nil
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
switch type.lowercased() {
|
|
480
|
+
case "heartrate", "heart_rate":
|
|
481
|
+
if let zone = config["zone"] as? Int {
|
|
482
|
+
return .heartRate(zone: zone)
|
|
483
|
+
} else if let min = config["min"] as? Double, let max = config["max"] as? Double {
|
|
484
|
+
return .heartRate(Double(min)...Double(max), unit: .beatsPerMinute)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
case "pace":
|
|
488
|
+
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
489
|
+
return nil
|
|
490
|
+
}
|
|
491
|
+
let unitStr = config["unit"] as? String ?? "minutesPerKilometer"
|
|
492
|
+
let lengthUnit = self.parsePaceUnit(unitStr)
|
|
493
|
+
return .pace(min...max, unit: lengthUnit, metric: .current)
|
|
494
|
+
|
|
495
|
+
case "speed":
|
|
496
|
+
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
497
|
+
return nil
|
|
498
|
+
}
|
|
499
|
+
let unitStr = config["unit"] as? String ?? "metersPerSecond"
|
|
500
|
+
let unit = self.parseSpeedUnit(unitStr)
|
|
501
|
+
return .speed(min...max, unit: unit, metric: .current)
|
|
502
|
+
|
|
503
|
+
case "cadence":
|
|
504
|
+
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
505
|
+
return nil
|
|
506
|
+
}
|
|
507
|
+
return .cadence(min...max, unit: .stepsPerMinute, metric: .current)
|
|
508
|
+
|
|
509
|
+
case "power":
|
|
510
|
+
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
511
|
+
return nil
|
|
512
|
+
}
|
|
513
|
+
return .power(min...max, unit: .watts, metric: .current)
|
|
514
|
+
|
|
515
|
+
default:
|
|
516
|
+
return nil
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return nil
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private func buildCustomWorkout(from config: [String: Any]) throws -> CustomWorkout {
|
|
523
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
524
|
+
let activity = self.parseActivityType(activityTypeStr) else {
|
|
525
|
+
throw Exception(name: "InvalidConfig", description: "Invalid or missing activityType")
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let location = self.parseLocationType(config["locationType"] as? String) ?? .outdoor
|
|
529
|
+
let displayName = config["displayName"] as? String
|
|
530
|
+
|
|
531
|
+
var warmup: WorkoutStep?
|
|
532
|
+
if let warmupConfig = config["warmup"] as? [String: Any] {
|
|
533
|
+
warmup = try self.parseWorkoutStep(from: warmupConfig)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
var cooldown: WorkoutStep?
|
|
537
|
+
if let cooldownConfig = config["cooldown"] as? [String: Any] {
|
|
538
|
+
cooldown = try self.parseWorkoutStep(from: cooldownConfig)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
var blocks: [IntervalBlock] = []
|
|
542
|
+
if let blocksConfig = config["blocks"] as? [[String: Any]] {
|
|
543
|
+
for blockConfig in blocksConfig {
|
|
544
|
+
let block = try self.parseIntervalBlock(from: blockConfig)
|
|
545
|
+
blocks.append(block)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return CustomWorkout(
|
|
550
|
+
activity: activity,
|
|
551
|
+
location: location,
|
|
552
|
+
displayName: displayName,
|
|
553
|
+
warmup: warmup,
|
|
554
|
+
blocks: blocks,
|
|
555
|
+
cooldown: cooldown
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private func parseWorkoutStep(from config: [String: Any]) throws -> WorkoutStep {
|
|
560
|
+
let goalConfig = config["goal"] as? [String: Any]
|
|
561
|
+
let goal: WorkoutGoal
|
|
562
|
+
|
|
563
|
+
if let goalConfig = goalConfig {
|
|
564
|
+
goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
565
|
+
} else {
|
|
566
|
+
goal = .open
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
var step = WorkoutStep(goal: goal)
|
|
570
|
+
|
|
571
|
+
if let alertConfig = config["alert"] as? [String: Any] {
|
|
572
|
+
step.alert = try self.parseWorkoutAlert(from: alertConfig)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return step
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private func parseIntervalStep(from config: [String: Any]) throws -> IntervalStep {
|
|
579
|
+
let purposeStr = config["purpose"] as? String ?? "work"
|
|
580
|
+
let purpose = self.parseStepPurpose(purposeStr)
|
|
581
|
+
|
|
582
|
+
var step = IntervalStep(purpose)
|
|
583
|
+
|
|
584
|
+
if let goalConfig = config["goal"] as? [String: Any] {
|
|
585
|
+
step.step.goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if let alertConfig = config["alert"] as? [String: Any] {
|
|
589
|
+
step.step.alert = try self.parseWorkoutAlert(from: alertConfig)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return step
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private func parseIntervalBlock(from config: [String: Any]) throws -> IntervalBlock {
|
|
596
|
+
var block = IntervalBlock()
|
|
597
|
+
|
|
598
|
+
if let iterations = config["iterations"] as? Int {
|
|
599
|
+
block.iterations = iterations
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if let stepsConfig = config["steps"] as? [[String: Any]] {
|
|
603
|
+
var steps: [IntervalStep] = []
|
|
604
|
+
for stepConfig in stepsConfig {
|
|
605
|
+
let step = try self.parseIntervalStep(from: stepConfig)
|
|
606
|
+
steps.append(step)
|
|
607
|
+
}
|
|
608
|
+
block.steps = steps
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return block
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private func parseDateComponents(from dict: [String: Any]) -> DateComponents {
|
|
615
|
+
var components = DateComponents()
|
|
616
|
+
|
|
617
|
+
if let year = dict["year"] as? Int {
|
|
618
|
+
components.year = year
|
|
619
|
+
}
|
|
620
|
+
if let month = dict["month"] as? Int {
|
|
621
|
+
components.month = month
|
|
622
|
+
}
|
|
623
|
+
if let day = dict["day"] as? Int {
|
|
624
|
+
components.day = day
|
|
625
|
+
}
|
|
626
|
+
if let hour = dict["hour"] as? Int {
|
|
627
|
+
components.hour = hour
|
|
628
|
+
}
|
|
629
|
+
if let minute = dict["minute"] as? Int {
|
|
630
|
+
components.minute = minute
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return components
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private func dateComponentsToDict(_ components: DateComponents) -> [String: Any] {
|
|
637
|
+
var dict: [String: Any] = [:]
|
|
638
|
+
|
|
639
|
+
if let year = components.year {
|
|
640
|
+
dict["year"] = year
|
|
641
|
+
}
|
|
642
|
+
if let month = components.month {
|
|
643
|
+
dict["month"] = month
|
|
644
|
+
}
|
|
645
|
+
if let day = components.day {
|
|
646
|
+
dict["day"] = day
|
|
647
|
+
}
|
|
648
|
+
if let hour = components.hour {
|
|
649
|
+
dict["hour"] = hour
|
|
650
|
+
}
|
|
651
|
+
if let minute = components.minute {
|
|
652
|
+
dict["minute"] = minute
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return dict
|
|
656
|
+
}
|
|
657
|
+
}
|