react-native-workouts 0.1.0 → 0.2.3
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/README.md +176 -172
- package/app.json +3 -0
- package/build/ReactNativeWorkouts.types.d.ts +106 -20
- package/build/ReactNativeWorkouts.types.d.ts.map +1 -1
- package/build/ReactNativeWorkouts.types.js +3 -1
- package/build/ReactNativeWorkouts.types.js.map +1 -1
- package/build/ReactNativeWorkoutsModule.d.ts +76 -2
- package/build/ReactNativeWorkoutsModule.d.ts.map +1 -1
- package/build/ReactNativeWorkoutsModule.js +2 -2
- package/build/ReactNativeWorkoutsModule.js.map +1 -1
- package/build/hooks.d.ts +70 -0
- package/build/hooks.d.ts.map +1 -0
- package/build/hooks.js +194 -0
- package/build/hooks.js.map +1 -0
- package/build/index.d.ts +3 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -2
- package/build/index.js.map +1 -1
- package/expo-module.config.json +8 -4
- package/ios/ReactNativeWorkouts.podspec +8 -2
- package/ios/ReactNativeWorkoutsModule.swift +546 -137
- package/package.json +2 -2
- package/public/react-native-workouts-banner.png +0 -0
- package/src/ReactNativeWorkouts.types.ts +172 -75
- package/src/ReactNativeWorkoutsModule.ts +101 -18
- package/src/hooks.ts +295 -0
- package/src/index.ts +3 -2
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
import WorkoutKit
|
|
3
3
|
import HealthKit
|
|
4
|
+
import SwiftUI
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
// MARK: - Shared Objects
|
|
8
|
+
|
|
9
|
+
public final class WorkoutPlanObject: SharedObject {
|
|
10
|
+
// Keep the plan opaque so this object can be referenced in ModuleDefinition on iOS < 17.
|
|
11
|
+
// We only cast/use WorkoutKit types behind runtime availability checks.
|
|
12
|
+
fileprivate let planHandle: Any
|
|
13
|
+
fileprivate let planId: String
|
|
14
|
+
fileprivate let kind: String
|
|
15
|
+
fileprivate let sourceConfig: [String: Any]
|
|
16
|
+
|
|
17
|
+
fileprivate init(planHandle: Any, planId: String, kind: String, sourceConfig: [String: Any]) {
|
|
18
|
+
self.planHandle = planHandle
|
|
19
|
+
self.planId = planId
|
|
20
|
+
self.kind = kind
|
|
21
|
+
self.sourceConfig = sourceConfig
|
|
22
|
+
super.init()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fileprivate func export() -> [String: Any] {
|
|
26
|
+
return [
|
|
27
|
+
"id": planId,
|
|
28
|
+
"kind": kind,
|
|
29
|
+
"config": sourceConfig
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@MainActor
|
|
34
|
+
fileprivate func preview() async throws {
|
|
35
|
+
guard #available(iOS 17.0, *) else {
|
|
36
|
+
throw Exception(name: "Unavailable", description: "WorkoutKit requires iOS 17+. This API is unavailable on the current OS version.")
|
|
37
|
+
}
|
|
38
|
+
let plan = try self.getWorkoutPlan()
|
|
39
|
+
try await presentWorkoutPreview(plan)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fileprivate func schedule(at date: DateComponents) async throws -> [String: Any] {
|
|
43
|
+
guard #available(iOS 17.0, *) else {
|
|
44
|
+
throw Exception(name: "Unavailable", description: "WorkoutKit requires iOS 17+. This API is unavailable on the current OS version.")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let plan = try self.getWorkoutPlan()
|
|
48
|
+
await WorkoutScheduler.shared.schedule(plan, at: date)
|
|
49
|
+
return [
|
|
50
|
+
"success": true,
|
|
51
|
+
"id": planId
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@available(iOS 17.0, *)
|
|
56
|
+
private func getWorkoutPlan() throws -> WorkoutPlan {
|
|
57
|
+
guard let plan = planHandle as? WorkoutPlan else {
|
|
58
|
+
throw Exception(name: "InvalidState", description: "Workout plan handle is invalid")
|
|
59
|
+
}
|
|
60
|
+
return plan
|
|
61
|
+
}
|
|
62
|
+
}
|
|
4
63
|
|
|
5
64
|
public class ReactNativeWorkoutsModule: Module {
|
|
6
65
|
private let healthStore = HKHealthStore()
|
|
@@ -18,118 +77,291 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
18
77
|
|
|
19
78
|
Events("onAuthorizationChange")
|
|
20
79
|
|
|
80
|
+
let workoutKitUnavailableMessage = "WorkoutKit requires iOS 17+. This API is unavailable on the current OS version."
|
|
81
|
+
|
|
82
|
+
// MARK: - Shared Object API (WorkoutPlan)
|
|
83
|
+
Class("WorkoutPlan", WorkoutPlanObject.self) {
|
|
84
|
+
Property("id") { planObject in
|
|
85
|
+
return planObject.planId
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Property("kind") { planObject in
|
|
89
|
+
return planObject.kind
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
Function("export") { planObject in
|
|
93
|
+
return planObject.export()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
AsyncFunction("preview") { (planObject: WorkoutPlanObject) async throws -> Bool in
|
|
97
|
+
try await planObject.preview()
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Schedules the plan using Apple's WorkoutScheduler (this is how it syncs to the Watch Workout app).
|
|
102
|
+
AsyncFunction("scheduleAndSync") { (planObject: WorkoutPlanObject, date: [String: Any]) async throws -> [String: Any] in
|
|
103
|
+
let dateComponents = self.parseDateComponents(from: date)
|
|
104
|
+
return try await planObject.schedule(at: dateComponents)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
21
108
|
// MARK: - Authorization
|
|
22
109
|
|
|
23
|
-
AsyncFunction("getAuthorizationStatus") { () -> String in
|
|
110
|
+
AsyncFunction("getAuthorizationStatus") { () async throws -> String in
|
|
111
|
+
guard #available(iOS 17.0, *) else {
|
|
112
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
113
|
+
}
|
|
114
|
+
|
|
24
115
|
let status = await WorkoutScheduler.shared.authorizationState
|
|
25
116
|
return self.authorizationStateToString(status)
|
|
26
117
|
}
|
|
27
118
|
|
|
28
|
-
AsyncFunction("requestAuthorization") { () -> String in
|
|
119
|
+
AsyncFunction("requestAuthorization") { () async throws -> String in
|
|
120
|
+
guard #available(iOS 17.0, *) else {
|
|
121
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
122
|
+
}
|
|
123
|
+
|
|
29
124
|
let status = await WorkoutScheduler.shared.requestAuthorization()
|
|
30
125
|
return self.authorizationStateToString(status)
|
|
31
126
|
}
|
|
32
127
|
|
|
33
128
|
// MARK: - Workout Validation
|
|
34
129
|
|
|
35
|
-
AsyncFunction("supportsGoal") { (activityType: String, locationType: String, goalType: String) -> Bool in
|
|
130
|
+
AsyncFunction("supportsGoal") { (activityType: String, locationType: String, goalType: String) throws -> Bool in
|
|
131
|
+
guard #available(iOS 17.0, *) else {
|
|
132
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
133
|
+
}
|
|
134
|
+
|
|
36
135
|
guard let activity = self.parseActivityType(activityType),
|
|
37
136
|
let location = self.parseLocationType(locationType),
|
|
38
137
|
let goal = self.parseGoalTypeForValidation(goalType) else {
|
|
39
138
|
return false
|
|
40
139
|
}
|
|
41
140
|
|
|
42
|
-
return CustomWorkout.
|
|
141
|
+
return CustomWorkout.supportsGoal(goal, activity: activity, location: location)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - Plan factories (return a WorkoutPlanObject handle to JS)
|
|
145
|
+
|
|
146
|
+
AsyncFunction("createCustomWorkoutPlan") { (config: [String: Any]) throws -> WorkoutPlanObject in
|
|
147
|
+
guard #available(iOS 17.0, *) else {
|
|
148
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let workout = try self.buildCustomWorkout(from: config)
|
|
152
|
+
try self.validateCustomWorkout(workout)
|
|
153
|
+
let plan = WorkoutPlan(.custom(workout))
|
|
154
|
+
return WorkoutPlanObject(planHandle: plan, planId: plan.id.uuidString, kind: "custom", sourceConfig: config)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
AsyncFunction("createSingleGoalWorkoutPlan") { (config: [String: Any]) throws -> WorkoutPlanObject in
|
|
158
|
+
guard #available(iOS 17.0, *) else {
|
|
159
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
163
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
164
|
+
let goalConfig = config["goal"] as? [String: Any] else {
|
|
165
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, goal")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
169
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
170
|
+
|
|
171
|
+
guard SingleGoalWorkout.supportsGoal(goal, activity: activity, location: location) else {
|
|
172
|
+
throw Exception(name: "ValidationError", description: "Single goal workout not supported for this activity/location/goal")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let workout = SingleGoalWorkout(activity: activity, location: location, goal: goal)
|
|
176
|
+
let plan = WorkoutPlan(.goal(workout))
|
|
177
|
+
return WorkoutPlanObject(planHandle: plan, planId: plan.id.uuidString, kind: "singleGoal", sourceConfig: config)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
AsyncFunction("createPacerWorkoutPlan") { (config: [String: Any]) throws -> WorkoutPlanObject in
|
|
181
|
+
guard #available(iOS 17.0, *) else {
|
|
182
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
186
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
187
|
+
let targetConfig = config["target"] as? [String: Any] else {
|
|
188
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, target")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
192
|
+
let (distance, time) = try self.parsePacerDistanceAndTime(from: targetConfig)
|
|
193
|
+
|
|
194
|
+
let workout = PacerWorkout(activity: activity, location: location, distance: distance, time: time)
|
|
195
|
+
let plan = WorkoutPlan(.pacer(workout))
|
|
196
|
+
return WorkoutPlanObject(planHandle: plan, planId: plan.id.uuidString, kind: "pacer", sourceConfig: config)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
AsyncFunction("createSwimBikeRunWorkoutPlan") { (config: [String: Any]) throws -> WorkoutPlanObject in
|
|
200
|
+
guard #available(iOS 17.0, *) else {
|
|
201
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
guard let activitiesConfig = config["activities"] as? [[String: Any]] else {
|
|
205
|
+
throw Exception(name: "InvalidConfig", description: "Missing required field: activities")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let displayName = config["displayName"] as? String
|
|
209
|
+
let activities = try self.parseSwimBikeRunActivities(from: activitiesConfig)
|
|
210
|
+
|
|
211
|
+
guard SwimBikeRunWorkout.supportsActivityOrdering(activities) else {
|
|
212
|
+
throw Exception(name: "ValidationError", description: "Unsupported activity ordering for SwimBikeRun workout")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let workout = SwimBikeRunWorkout(activities: activities, displayName: displayName)
|
|
216
|
+
let plan = WorkoutPlan(.swimBikeRun(workout))
|
|
217
|
+
return WorkoutPlanObject(planHandle: plan, planId: plan.id.uuidString, kind: "swimBikeRun", sourceConfig: config)
|
|
43
218
|
}
|
|
44
219
|
|
|
45
220
|
// MARK: - Custom Workout Creation
|
|
46
221
|
|
|
47
222
|
AsyncFunction("createCustomWorkout") { (config: [String: Any]) throws -> [String: Any] in
|
|
223
|
+
guard #available(iOS 17.0, *) else {
|
|
224
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
225
|
+
}
|
|
226
|
+
|
|
48
227
|
let workout = try self.buildCustomWorkout(from: config)
|
|
49
|
-
|
|
228
|
+
try self.validateCustomWorkout(workout)
|
|
50
229
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"displayName": workout.displayName ?? ""
|
|
56
|
-
]
|
|
57
|
-
} catch {
|
|
58
|
-
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
59
|
-
}
|
|
230
|
+
return [
|
|
231
|
+
"valid": true,
|
|
232
|
+
"displayName": workout.displayName ?? (config["displayName"] as? String ?? "")
|
|
233
|
+
]
|
|
60
234
|
}
|
|
61
235
|
|
|
62
|
-
// MARK: -
|
|
236
|
+
// MARK: - Workout Preview (system modal)
|
|
237
|
+
|
|
238
|
+
AsyncFunction("previewWorkout") { (config: [String: Any]) async throws -> Bool in
|
|
239
|
+
guard #available(iOS 17.0, *) else {
|
|
240
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
241
|
+
}
|
|
63
242
|
|
|
64
|
-
AsyncFunction("scheduleWorkout") { (config: [String: Any], date: [String: Any]) throws -> [String: Any] in
|
|
65
243
|
let workout = try self.buildCustomWorkout(from: config)
|
|
66
|
-
|
|
244
|
+
try self.validateCustomWorkout(workout)
|
|
245
|
+
let plan = WorkoutPlan(.custom(workout))
|
|
67
246
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
247
|
+
try await presentWorkoutPreview(plan)
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
AsyncFunction("previewSingleGoalWorkout") { (config: [String: Any]) async throws -> Bool in
|
|
252
|
+
guard #available(iOS 17.0, *) else {
|
|
253
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
72
254
|
}
|
|
73
255
|
|
|
74
|
-
let
|
|
75
|
-
|
|
256
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
257
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
258
|
+
let goalConfig = config["goal"] as? [String: Any] else {
|
|
259
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, goal")
|
|
260
|
+
}
|
|
76
261
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
]
|
|
83
|
-
} catch {
|
|
84
|
-
throw Exception(name: "ScheduleError", description: error.localizedDescription)
|
|
262
|
+
let goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
263
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
264
|
+
|
|
265
|
+
guard SingleGoalWorkout.supportsGoal(goal, activity: activity, location: location) else {
|
|
266
|
+
throw Exception(name: "ValidationError", description: "Single goal workout not supported for this activity/location/goal")
|
|
85
267
|
}
|
|
268
|
+
|
|
269
|
+
let workout = SingleGoalWorkout(activity: activity, location: location, goal: goal)
|
|
270
|
+
let plan = WorkoutPlan(.goal(workout))
|
|
271
|
+
|
|
272
|
+
try await presentWorkoutPreview(plan)
|
|
273
|
+
return true
|
|
86
274
|
}
|
|
87
275
|
|
|
88
|
-
AsyncFunction("
|
|
276
|
+
AsyncFunction("previewPacerWorkout") { (config: [String: Any]) async throws -> Bool in
|
|
277
|
+
guard #available(iOS 17.0, *) else {
|
|
278
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
guard let activityTypeStr = config["activityType"] as? String,
|
|
282
|
+
let activity = self.parseActivityType(activityTypeStr),
|
|
283
|
+
let targetConfig = config["target"] as? [String: Any] else {
|
|
284
|
+
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, target")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
288
|
+
let (distance, time) = try self.parsePacerDistanceAndTime(from: targetConfig)
|
|
289
|
+
|
|
290
|
+
let workout = PacerWorkout(activity: activity, location: location, distance: distance, time: time)
|
|
291
|
+
let plan = WorkoutPlan(.pacer(workout))
|
|
292
|
+
|
|
293
|
+
try await presentWorkoutPreview(plan)
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// MARK: - Scheduled Workouts
|
|
298
|
+
|
|
299
|
+
AsyncFunction("scheduleWorkout") { (config: [String: Any], date: [String: Any]) async throws -> [String: Any] in
|
|
300
|
+
guard #available(iOS 17.0, *) else {
|
|
301
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let workout = try self.buildCustomWorkout(from: config)
|
|
305
|
+
try self.validateCustomWorkout(workout)
|
|
306
|
+
|
|
307
|
+
let plan = WorkoutPlan(.custom(workout))
|
|
308
|
+
let dateComponents = self.parseDateComponents(from: date)
|
|
309
|
+
|
|
310
|
+
await WorkoutScheduler.shared.schedule(plan, at: dateComponents)
|
|
311
|
+
return [
|
|
312
|
+
"success": true,
|
|
313
|
+
"id": plan.id.uuidString
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
AsyncFunction("getScheduledWorkouts") { () async throws -> [[String: Any]] in
|
|
318
|
+
guard #available(iOS 17.0, *) else {
|
|
319
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
320
|
+
}
|
|
321
|
+
|
|
89
322
|
let workouts = await WorkoutScheduler.shared.scheduledWorkouts
|
|
90
|
-
return workouts.map {
|
|
323
|
+
return workouts.map { scheduled in
|
|
91
324
|
return [
|
|
92
|
-
"id": plan.id.uuidString,
|
|
93
|
-
"date": self.dateComponentsToDict(
|
|
325
|
+
"id": scheduled.plan.id.uuidString,
|
|
326
|
+
"date": self.dateComponentsToDict(scheduled.date)
|
|
94
327
|
]
|
|
95
328
|
}
|
|
96
329
|
}
|
|
97
330
|
|
|
98
|
-
AsyncFunction("removeScheduledWorkout") { (id: String) throws -> Bool in
|
|
331
|
+
AsyncFunction("removeScheduledWorkout") { (id: String) async throws -> Bool in
|
|
332
|
+
guard #available(iOS 17.0, *) else {
|
|
333
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
334
|
+
}
|
|
335
|
+
|
|
99
336
|
guard let uuid = UUID(uuidString: id) else {
|
|
100
337
|
throw Exception(name: "InvalidID", description: "Invalid workout ID format")
|
|
101
338
|
}
|
|
102
339
|
|
|
103
340
|
let workouts = await WorkoutScheduler.shared.scheduledWorkouts
|
|
104
|
-
guard let workout = workouts.first(where: { $0.id == uuid }) else {
|
|
341
|
+
guard let workout = workouts.first(where: { $0.plan.id == uuid }) else {
|
|
105
342
|
throw Exception(name: "NotFound", description: "Workout not found")
|
|
106
343
|
}
|
|
107
344
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return true
|
|
111
|
-
} catch {
|
|
112
|
-
throw Exception(name: "RemoveError", description: error.localizedDescription)
|
|
113
|
-
}
|
|
345
|
+
await WorkoutScheduler.shared.remove(workout.plan, at: workout.date)
|
|
346
|
+
return true
|
|
114
347
|
}
|
|
115
348
|
|
|
116
|
-
AsyncFunction("removeAllScheduledWorkouts") { () throws -> Bool in
|
|
117
|
-
|
|
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
|
-
}
|
|
349
|
+
AsyncFunction("removeAllScheduledWorkouts") { () async throws -> Bool in
|
|
350
|
+
guard #available(iOS 17.0, *) else {
|
|
351
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
125
352
|
}
|
|
126
353
|
|
|
354
|
+
await WorkoutScheduler.shared.removeAllWorkouts()
|
|
127
355
|
return true
|
|
128
356
|
}
|
|
129
357
|
|
|
130
358
|
// MARK: - Single Goal Workout
|
|
131
359
|
|
|
132
360
|
AsyncFunction("createSingleGoalWorkout") { (config: [String: Any]) throws -> [String: Any] in
|
|
361
|
+
guard #available(iOS 17.0, *) else {
|
|
362
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
363
|
+
}
|
|
364
|
+
|
|
133
365
|
guard let activityTypeStr = config["activityType"] as? String,
|
|
134
366
|
let activity = self.parseActivityType(activityTypeStr),
|
|
135
367
|
let goalConfig = config["goal"] as? [String: Any] else {
|
|
@@ -138,23 +370,23 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
138
370
|
|
|
139
371
|
let goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
140
372
|
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
141
|
-
let displayName = config["displayName"] as? String
|
|
373
|
+
let displayName = config["displayName"] as? String ?? ""
|
|
142
374
|
|
|
143
|
-
|
|
144
|
-
|
|
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)
|
|
375
|
+
guard SingleGoalWorkout.supportsGoal(goal, activity: activity, location: location) else {
|
|
376
|
+
throw Exception(name: "ValidationError", description: "Single goal workout not supported for this activity/location/goal")
|
|
154
377
|
}
|
|
378
|
+
|
|
379
|
+
return [
|
|
380
|
+
"valid": true,
|
|
381
|
+
"displayName": displayName
|
|
382
|
+
]
|
|
155
383
|
}
|
|
156
384
|
|
|
157
|
-
AsyncFunction("scheduleSingleGoalWorkout") { (config: [String: Any], date: [String: Any]) throws -> [String: Any] in
|
|
385
|
+
AsyncFunction("scheduleSingleGoalWorkout") { (config: [String: Any], date: [String: Any]) async throws -> [String: Any] in
|
|
386
|
+
guard #available(iOS 17.0, *) else {
|
|
387
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
388
|
+
}
|
|
389
|
+
|
|
158
390
|
guard let activityTypeStr = config["activityType"] as? String,
|
|
159
391
|
let activity = self.parseActivityType(activityTypeStr),
|
|
160
392
|
let goalConfig = config["goal"] as? [String: Any] else {
|
|
@@ -163,90 +395,70 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
163
395
|
|
|
164
396
|
let goal = try self.parseWorkoutGoal(from: goalConfig)
|
|
165
397
|
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
398
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
} catch {
|
|
174
|
-
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
399
|
+
guard SingleGoalWorkout.supportsGoal(goal, activity: activity, location: location) else {
|
|
400
|
+
throw Exception(name: "ValidationError", description: "Single goal workout not supported for this activity/location/goal")
|
|
175
401
|
}
|
|
176
402
|
|
|
177
|
-
let
|
|
178
|
-
let plan =
|
|
403
|
+
let workout = SingleGoalWorkout(activity: activity, location: location, goal: goal)
|
|
404
|
+
let plan = WorkoutPlan(.goal(workout))
|
|
179
405
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
} catch {
|
|
187
|
-
throw Exception(name: "ScheduleError", description: error.localizedDescription)
|
|
188
|
-
}
|
|
406
|
+
let dateComponents = self.parseDateComponents(from: date)
|
|
407
|
+
await WorkoutScheduler.shared.schedule(plan, at: dateComponents)
|
|
408
|
+
return [
|
|
409
|
+
"success": true,
|
|
410
|
+
"id": plan.id.uuidString
|
|
411
|
+
]
|
|
189
412
|
}
|
|
190
413
|
|
|
191
414
|
// MARK: - Pacer Workout
|
|
192
415
|
|
|
193
416
|
AsyncFunction("createPacerWorkout") { (config: [String: Any]) throws -> [String: Any] in
|
|
417
|
+
guard #available(iOS 17.0, *) else {
|
|
418
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
419
|
+
}
|
|
420
|
+
|
|
194
421
|
guard let activityTypeStr = config["activityType"] as? String,
|
|
195
422
|
let activity = self.parseActivityType(activityTypeStr),
|
|
196
423
|
let targetConfig = config["target"] as? [String: Any] else {
|
|
197
424
|
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, target")
|
|
198
425
|
}
|
|
199
426
|
|
|
200
|
-
let target = try self.parsePacerTarget(from: targetConfig)
|
|
201
427
|
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
202
|
-
let displayName = config["displayName"] as? String
|
|
428
|
+
let displayName = config["displayName"] as? String ?? ""
|
|
203
429
|
|
|
204
|
-
let
|
|
205
|
-
|
|
430
|
+
let (distance, time) = try self.parsePacerDistanceAndTime(from: targetConfig)
|
|
431
|
+
_ = PacerWorkout(activity: activity, location: location, distance: distance, time: time)
|
|
206
432
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
"displayName": workout.displayName ?? ""
|
|
212
|
-
]
|
|
213
|
-
} catch {
|
|
214
|
-
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
215
|
-
}
|
|
433
|
+
return [
|
|
434
|
+
"valid": true,
|
|
435
|
+
"displayName": displayName
|
|
436
|
+
]
|
|
216
437
|
}
|
|
217
438
|
|
|
218
|
-
AsyncFunction("schedulePacerWorkout") { (config: [String: Any], date: [String: Any]) throws -> [String: Any] in
|
|
439
|
+
AsyncFunction("schedulePacerWorkout") { (config: [String: Any], date: [String: Any]) async throws -> [String: Any] in
|
|
440
|
+
guard #available(iOS 17.0, *) else {
|
|
441
|
+
throw Exception(name: "Unavailable", description: workoutKitUnavailableMessage)
|
|
442
|
+
}
|
|
443
|
+
|
|
219
444
|
guard let activityTypeStr = config["activityType"] as? String,
|
|
220
445
|
let activity = self.parseActivityType(activityTypeStr),
|
|
221
446
|
let targetConfig = config["target"] as? [String: Any] else {
|
|
222
447
|
throw Exception(name: "InvalidConfig", description: "Missing required fields: activityType, target")
|
|
223
448
|
}
|
|
224
449
|
|
|
225
|
-
let target = try self.parsePacerTarget(from: targetConfig)
|
|
226
450
|
let location = self.parseLocationType(config["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
227
|
-
let displayName = config["displayName"] as? String
|
|
228
451
|
|
|
229
|
-
let
|
|
230
|
-
let
|
|
231
|
-
|
|
232
|
-
do {
|
|
233
|
-
try await composition.validate()
|
|
234
|
-
} catch {
|
|
235
|
-
throw Exception(name: "ValidationError", description: error.localizedDescription)
|
|
236
|
-
}
|
|
452
|
+
let (distance, time) = try self.parsePacerDistanceAndTime(from: targetConfig)
|
|
453
|
+
let workout = PacerWorkout(activity: activity, location: location, distance: distance, time: time)
|
|
454
|
+
let plan = WorkoutPlan(.pacer(workout))
|
|
237
455
|
|
|
238
456
|
let dateComponents = self.parseDateComponents(from: date)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"success": true,
|
|
245
|
-
"id": plan.id.uuidString
|
|
246
|
-
]
|
|
247
|
-
} catch {
|
|
248
|
-
throw Exception(name: "ScheduleError", description: error.localizedDescription)
|
|
249
|
-
}
|
|
457
|
+
await WorkoutScheduler.shared.schedule(plan, at: dateComponents)
|
|
458
|
+
return [
|
|
459
|
+
"success": true,
|
|
460
|
+
"id": plan.id.uuidString
|
|
461
|
+
]
|
|
250
462
|
}
|
|
251
463
|
|
|
252
464
|
// MARK: - Activity Types
|
|
@@ -296,6 +508,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
296
508
|
|
|
297
509
|
// MARK: - Helper Methods
|
|
298
510
|
|
|
511
|
+
@available(iOS 17.0, *)
|
|
299
512
|
private func authorizationStateToString(_ state: WorkoutScheduler.AuthorizationState) -> String {
|
|
300
513
|
switch state {
|
|
301
514
|
case .authorized:
|
|
@@ -304,11 +517,50 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
304
517
|
return "notDetermined"
|
|
305
518
|
case .denied:
|
|
306
519
|
return "denied"
|
|
307
|
-
|
|
520
|
+
default:
|
|
308
521
|
return "unknown"
|
|
309
522
|
}
|
|
310
523
|
}
|
|
311
524
|
|
|
525
|
+
@available(iOS 17.0, *)
|
|
526
|
+
private func parseSwimBikeRunActivities(from activitiesConfig: [[String: Any]]) throws -> [SwimBikeRunWorkout.Activity] {
|
|
527
|
+
var activities: [SwimBikeRunWorkout.Activity] = []
|
|
528
|
+
|
|
529
|
+
for activityConfig in activitiesConfig {
|
|
530
|
+
guard let type = activityConfig["type"] as? String else {
|
|
531
|
+
throw Exception(name: "InvalidConfig", description: "Each activity must have a type")
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
switch type.lowercased() {
|
|
535
|
+
case "running":
|
|
536
|
+
let location = self.parseLocationType(activityConfig["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
537
|
+
activities.append(.running(location))
|
|
538
|
+
case "cycling":
|
|
539
|
+
let location = self.parseLocationType(activityConfig["locationType"] as? String ?? "outdoor") ?? .outdoor
|
|
540
|
+
activities.append(.cycling(location))
|
|
541
|
+
case "swimming":
|
|
542
|
+
let swimLocation = self.parseSwimmingLocationType(activityConfig["locationType"] as? String ?? "pool")
|
|
543
|
+
activities.append(.swimming(swimLocation))
|
|
544
|
+
default:
|
|
545
|
+
throw Exception(name: "InvalidConfig", description: "Unsupported SwimBikeRun activity type: \(type)")
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return activities
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func parseSwimmingLocationType(_ type: String?) -> HKWorkoutSwimmingLocationType {
|
|
553
|
+
guard let type = type else { return .unknown }
|
|
554
|
+
switch type.lowercased() {
|
|
555
|
+
case "pool", "indoor":
|
|
556
|
+
return .pool
|
|
557
|
+
case "openwater", "open_water", "outdoor":
|
|
558
|
+
return .openWater
|
|
559
|
+
default:
|
|
560
|
+
return .unknown
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
312
564
|
private func parseActivityType(_ type: String) -> HKWorkoutActivityType? {
|
|
313
565
|
switch type.lowercased() {
|
|
314
566
|
case "running": return .running
|
|
@@ -323,7 +575,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
323
575
|
case "yoga": return .yoga
|
|
324
576
|
case "functionalstrengthtraining": return .functionalStrengthTraining
|
|
325
577
|
case "traditionalstrengthtraining": return .traditionalStrengthTraining
|
|
326
|
-
case "dance": return .
|
|
578
|
+
case "dance": return .cardioDance
|
|
327
579
|
case "jumprope": return .jumpRope
|
|
328
580
|
case "coretraining": return .coreTraining
|
|
329
581
|
case "pilates": return .pilates
|
|
@@ -336,15 +588,16 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
336
588
|
}
|
|
337
589
|
}
|
|
338
590
|
|
|
339
|
-
private func parseLocationType(_ type: String?) ->
|
|
591
|
+
private func parseLocationType(_ type: String?) -> HKWorkoutSessionLocationType? {
|
|
340
592
|
guard let type = type else { return .outdoor }
|
|
341
593
|
switch type.lowercased() {
|
|
342
594
|
case "indoor": return .indoor
|
|
343
595
|
case "outdoor": return .outdoor
|
|
344
|
-
default: return .
|
|
596
|
+
default: return .unknown
|
|
345
597
|
}
|
|
346
598
|
}
|
|
347
599
|
|
|
600
|
+
@available(iOS 17.0, *)
|
|
348
601
|
private func parseGoalTypeForValidation(_ type: String) -> WorkoutGoal? {
|
|
349
602
|
switch type.lowercased() {
|
|
350
603
|
case "open": return .open
|
|
@@ -355,6 +608,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
355
608
|
}
|
|
356
609
|
}
|
|
357
610
|
|
|
611
|
+
@available(iOS 17.0, *)
|
|
358
612
|
private func parseWorkoutGoal(from config: [String: Any]) throws -> WorkoutGoal {
|
|
359
613
|
guard let type = config["type"] as? String else {
|
|
360
614
|
throw Exception(name: "InvalidGoal", description: "Goal type is required")
|
|
@@ -421,7 +675,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
421
675
|
}
|
|
422
676
|
}
|
|
423
677
|
|
|
424
|
-
private func
|
|
678
|
+
private func parsePacerDistanceAndTime(from config: [String: Any]) throws -> (distance: Measurement<UnitLength>, time: Measurement<UnitDuration>) {
|
|
425
679
|
guard let type = config["type"] as? String else {
|
|
426
680
|
throw Exception(name: "InvalidTarget", description: "Target type is required")
|
|
427
681
|
}
|
|
@@ -431,15 +685,37 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
431
685
|
}
|
|
432
686
|
|
|
433
687
|
switch type.lowercased() {
|
|
688
|
+
case "pace":
|
|
689
|
+
// value is minutes per unitLength (km or mile)
|
|
690
|
+
let unitStr = config["unit"] as? String ?? "minutesPerKilometer"
|
|
691
|
+
let lengthUnit = self.parsePaceUnit(unitStr)
|
|
692
|
+
let distance = Measurement(value: 1, unit: lengthUnit)
|
|
693
|
+
let time = Measurement(value: value, unit: UnitDuration.minutes)
|
|
694
|
+
return (distance, time)
|
|
695
|
+
|
|
434
696
|
case "speed":
|
|
697
|
+
// value is speed in the given unit; we convert it to a distance/time pair
|
|
435
698
|
let unitStr = config["unit"] as? String ?? "metersPerSecond"
|
|
436
|
-
let
|
|
437
|
-
|
|
699
|
+
let speedUnit = self.parseSpeedUnit(unitStr)
|
|
700
|
+
let speed = Measurement(value: value, unit: speedUnit)
|
|
701
|
+
|
|
702
|
+
let preferredDistance: Measurement<UnitLength>
|
|
703
|
+
switch unitStr.lowercased() {
|
|
704
|
+
case "milesperhour", "mph":
|
|
705
|
+
preferredDistance = Measurement(value: 1, unit: UnitLength.miles)
|
|
706
|
+
case "kilometersperhour", "kph", "km/h":
|
|
707
|
+
preferredDistance = Measurement(value: 1, unit: UnitLength.kilometers)
|
|
708
|
+
default:
|
|
709
|
+
preferredDistance = Measurement(value: 1000, unit: UnitLength.meters)
|
|
710
|
+
}
|
|
438
711
|
|
|
439
|
-
|
|
440
|
-
let
|
|
441
|
-
|
|
442
|
-
|
|
712
|
+
let speedMps = speed.converted(to: UnitSpeed.metersPerSecond).value
|
|
713
|
+
let distanceMeters = preferredDistance.converted(to: UnitLength.meters).value
|
|
714
|
+
guard speedMps > 0 else {
|
|
715
|
+
throw Exception(name: "InvalidTarget", description: "Speed must be > 0")
|
|
716
|
+
}
|
|
717
|
+
let seconds = distanceMeters / speedMps
|
|
718
|
+
return (preferredDistance, Measurement(value: seconds, unit: UnitDuration.seconds))
|
|
443
719
|
|
|
444
720
|
default:
|
|
445
721
|
throw Exception(name: "InvalidTarget", description: "Unknown target type: \(type)")
|
|
@@ -463,6 +739,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
463
739
|
}
|
|
464
740
|
}
|
|
465
741
|
|
|
742
|
+
@available(iOS 17.0, *)
|
|
466
743
|
private func parseStepPurpose(_ purpose: String) -> IntervalStep.Purpose {
|
|
467
744
|
switch purpose.lowercased() {
|
|
468
745
|
case "work": return .work
|
|
@@ -471,7 +748,8 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
471
748
|
}
|
|
472
749
|
}
|
|
473
750
|
|
|
474
|
-
|
|
751
|
+
@available(iOS 17.0, *)
|
|
752
|
+
private func parseWorkoutAlert(from config: [String: Any]) throws -> (any WorkoutAlert)? {
|
|
475
753
|
guard let type = config["type"] as? String else {
|
|
476
754
|
return nil
|
|
477
755
|
}
|
|
@@ -479,9 +757,9 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
479
757
|
switch type.lowercased() {
|
|
480
758
|
case "heartrate", "heart_rate":
|
|
481
759
|
if let zone = config["zone"] as? Int {
|
|
482
|
-
return .heartRate(zone: zone)
|
|
760
|
+
return HeartRateZoneAlert.heartRate(zone: zone)
|
|
483
761
|
} else if let min = config["min"] as? Double, let max = config["max"] as? Double {
|
|
484
|
-
return .heartRate(Double(min)...Double(max)
|
|
762
|
+
return HeartRateRangeAlert.heartRate(Double(min)...Double(max))
|
|
485
763
|
}
|
|
486
764
|
|
|
487
765
|
case "pace":
|
|
@@ -490,7 +768,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
490
768
|
}
|
|
491
769
|
let unitStr = config["unit"] as? String ?? "minutesPerKilometer"
|
|
492
770
|
let lengthUnit = self.parsePaceUnit(unitStr)
|
|
493
|
-
return .
|
|
771
|
+
return try self.paceRangeAlert(minMinutesPerUnit: min, maxMinutesPerUnit: max, unit: lengthUnit)
|
|
494
772
|
|
|
495
773
|
case "speed":
|
|
496
774
|
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
@@ -498,19 +776,22 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
498
776
|
}
|
|
499
777
|
let unitStr = config["unit"] as? String ?? "metersPerSecond"
|
|
500
778
|
let unit = self.parseSpeedUnit(unitStr)
|
|
501
|
-
return .speed(min...max, unit: unit
|
|
779
|
+
return SpeedRangeAlert.speed(min...max, unit: unit)
|
|
502
780
|
|
|
503
781
|
case "cadence":
|
|
504
782
|
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
505
783
|
return nil
|
|
506
784
|
}
|
|
507
|
-
return .cadence(min...max
|
|
785
|
+
return CadenceRangeAlert.cadence(min...max)
|
|
508
786
|
|
|
509
787
|
case "power":
|
|
510
788
|
guard let min = config["min"] as? Double, let max = config["max"] as? Double else {
|
|
511
789
|
return nil
|
|
512
790
|
}
|
|
513
|
-
|
|
791
|
+
if #available(iOS 17.4, *) {
|
|
792
|
+
return PowerRangeAlert.power(min...max, unit: .watts, metric: .current)
|
|
793
|
+
}
|
|
794
|
+
return nil
|
|
514
795
|
|
|
515
796
|
default:
|
|
516
797
|
return nil
|
|
@@ -519,6 +800,54 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
519
800
|
return nil
|
|
520
801
|
}
|
|
521
802
|
|
|
803
|
+
@available(iOS 17.0, *)
|
|
804
|
+
private func paceRangeAlert(minMinutesPerUnit: Double, maxMinutesPerUnit: Double, unit: UnitLength) throws -> (any WorkoutAlert)? {
|
|
805
|
+
guard minMinutesPerUnit > 0, maxMinutesPerUnit > 0 else {
|
|
806
|
+
throw Exception(name: "InvalidAlert", description: "Pace min/max must be > 0")
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let distanceMeters = Measurement(value: 1, unit: unit).converted(to: .meters).value
|
|
810
|
+
let minSeconds = minMinutesPerUnit * 60.0
|
|
811
|
+
let maxSeconds = maxMinutesPerUnit * 60.0
|
|
812
|
+
|
|
813
|
+
// Pace is inverse of speed. For a range [minPace..maxPace] (minutes/unit),
|
|
814
|
+
// the equivalent speed range is [distance/maxPace .. distance/minPace].
|
|
815
|
+
let lowSpeed = distanceMeters / maxSeconds
|
|
816
|
+
let highSpeed = distanceMeters / minSeconds
|
|
817
|
+
return SpeedRangeAlert.speed(lowSpeed...highSpeed, unit: .metersPerSecond)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
@available(iOS 17.0, *)
|
|
821
|
+
private func validateCustomWorkout(_ workout: CustomWorkout) throws {
|
|
822
|
+
let activity = workout.activity
|
|
823
|
+
let location = workout.location
|
|
824
|
+
|
|
825
|
+
func validateStep(_ step: WorkoutStep) throws {
|
|
826
|
+
if !CustomWorkout.supportsGoal(step.goal, activity: activity, location: location) {
|
|
827
|
+
throw Exception(name: "ValidationError", description: "Unsupported workout goal for activity/location")
|
|
828
|
+
}
|
|
829
|
+
if let alert = step.alert {
|
|
830
|
+
// Prefer explicit validation for CustomWorkout context.
|
|
831
|
+
if !CustomWorkout.supportsAlert(alert, activity: activity, location: location) {
|
|
832
|
+
throw Exception(name: "ValidationError", description: "Unsupported workout alert for activity/location")
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if let warmup = workout.warmup {
|
|
838
|
+
try validateStep(warmup)
|
|
839
|
+
}
|
|
840
|
+
if let cooldown = workout.cooldown {
|
|
841
|
+
try validateStep(cooldown)
|
|
842
|
+
}
|
|
843
|
+
for block in workout.blocks {
|
|
844
|
+
for intervalStep in block.steps {
|
|
845
|
+
try validateStep(intervalStep.step)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
@available(iOS 17.0, *)
|
|
522
851
|
private func buildCustomWorkout(from config: [String: Any]) throws -> CustomWorkout {
|
|
523
852
|
guard let activityTypeStr = config["activityType"] as? String,
|
|
524
853
|
let activity = self.parseActivityType(activityTypeStr) else {
|
|
@@ -556,6 +885,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
556
885
|
)
|
|
557
886
|
}
|
|
558
887
|
|
|
888
|
+
@available(iOS 17.0, *)
|
|
559
889
|
private func parseWorkoutStep(from config: [String: Any]) throws -> WorkoutStep {
|
|
560
890
|
let goalConfig = config["goal"] as? [String: Any]
|
|
561
891
|
let goal: WorkoutGoal
|
|
@@ -575,6 +905,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
575
905
|
return step
|
|
576
906
|
}
|
|
577
907
|
|
|
908
|
+
@available(iOS 17.0, *)
|
|
578
909
|
private func parseIntervalStep(from config: [String: Any]) throws -> IntervalStep {
|
|
579
910
|
let purposeStr = config["purpose"] as? String ?? "work"
|
|
580
911
|
let purpose = self.parseStepPurpose(purposeStr)
|
|
@@ -592,6 +923,7 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
592
923
|
return step
|
|
593
924
|
}
|
|
594
925
|
|
|
926
|
+
@available(iOS 17.0, *)
|
|
595
927
|
private func parseIntervalBlock(from config: [String: Any]) throws -> IntervalBlock {
|
|
596
928
|
var block = IntervalBlock()
|
|
597
929
|
|
|
@@ -655,3 +987,80 @@ public class ReactNativeWorkoutsModule: Module {
|
|
|
655
987
|
return dict
|
|
656
988
|
}
|
|
657
989
|
}
|
|
990
|
+
|
|
991
|
+
// MARK: - Workout Preview presentation helpers
|
|
992
|
+
|
|
993
|
+
@MainActor
|
|
994
|
+
@available(iOS 17.0, *)
|
|
995
|
+
fileprivate func presentWorkoutPreview(_ plan: WorkoutPlan) async throws {
|
|
996
|
+
guard let viewController = topMostViewController() else {
|
|
997
|
+
throw Exception(name: "NoViewController", description: "Unable to find a view controller to present from")
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
let host = UIHostingController(rootView: WorkoutPreviewLauncher(plan: plan))
|
|
1001
|
+
host.modalPresentationStyle = .overFullScreen
|
|
1002
|
+
host.view.backgroundColor = .clear
|
|
1003
|
+
|
|
1004
|
+
viewController.present(host, animated: true)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
fileprivate func topMostViewController() -> UIViewController? {
|
|
1008
|
+
// React Native / Expo apps can temporarily be in `.foregroundInactive` during transitions.
|
|
1009
|
+
// Also, `isKeyWindow` isn't always set early, so we fall back to a normal-level window.
|
|
1010
|
+
let windowScenes = UIApplication.shared.connectedScenes
|
|
1011
|
+
.compactMap { $0 as? UIWindowScene }
|
|
1012
|
+
.filter { scene in
|
|
1013
|
+
switch scene.activationState {
|
|
1014
|
+
case .foregroundActive, .foregroundInactive:
|
|
1015
|
+
return true
|
|
1016
|
+
default:
|
|
1017
|
+
return false
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let candidateWindows = windowScenes.flatMap { $0.windows }
|
|
1022
|
+
let keyWindow =
|
|
1023
|
+
candidateWindows.first(where: { $0.isKeyWindow }) ??
|
|
1024
|
+
candidateWindows.first(where: { $0.windowLevel == .normal }) ??
|
|
1025
|
+
candidateWindows.first ??
|
|
1026
|
+
UIApplication.shared.windows.first(where: { $0.isKeyWindow }) ??
|
|
1027
|
+
UIApplication.shared.windows.first
|
|
1028
|
+
|
|
1029
|
+
guard let root = keyWindow?.rootViewController else { return nil }
|
|
1030
|
+
return topMostViewController(from: root)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
fileprivate func topMostViewController(from root: UIViewController) -> UIViewController {
|
|
1034
|
+
if let presented = root.presentedViewController {
|
|
1035
|
+
return topMostViewController(from: presented)
|
|
1036
|
+
}
|
|
1037
|
+
if let nav = root as? UINavigationController, let visible = nav.visibleViewController {
|
|
1038
|
+
return topMostViewController(from: visible)
|
|
1039
|
+
}
|
|
1040
|
+
if let tab = root as? UITabBarController, let selected = tab.selectedViewController {
|
|
1041
|
+
return topMostViewController(from: selected)
|
|
1042
|
+
}
|
|
1043
|
+
return root
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
@available(iOS 17.0, *)
|
|
1047
|
+
private struct WorkoutPreviewLauncher: View {
|
|
1048
|
+
let plan: WorkoutPlan
|
|
1049
|
+
|
|
1050
|
+
@State private var isPresented = false
|
|
1051
|
+
@Environment(\.dismiss) private var dismiss
|
|
1052
|
+
|
|
1053
|
+
var body: some View {
|
|
1054
|
+
Color.clear
|
|
1055
|
+
.ignoresSafeArea()
|
|
1056
|
+
.onAppear {
|
|
1057
|
+
isPresented = true
|
|
1058
|
+
}
|
|
1059
|
+
.workoutPreview(plan, isPresented: $isPresented)
|
|
1060
|
+
.onChange(of: isPresented) { presented in
|
|
1061
|
+
if !presented {
|
|
1062
|
+
dismiss()
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|