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.
@@ -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.supports(goal: goal, activity: activity, location: location)
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
- let composition = CustomWorkoutComposition(workout)
228
+ try self.validateCustomWorkout(workout)
50
229
 
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
- }
230
+ return [
231
+ "valid": true,
232
+ "displayName": workout.displayName ?? (config["displayName"] as? String ?? "")
233
+ ]
60
234
  }
61
235
 
62
- // MARK: - Scheduled Workouts
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
- let composition = CustomWorkoutComposition(workout)
244
+ try self.validateCustomWorkout(workout)
245
+ let plan = WorkoutPlan(.custom(workout))
67
246
 
68
- do {
69
- try await composition.validate()
70
- } catch {
71
- throw Exception(name: "ValidationError", description: error.localizedDescription)
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 dateComponents = self.parseDateComponents(from: date)
75
- let plan = ScheduledWorkoutPlan(workout: composition, date: dateComponents)
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
- 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)
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("getScheduledWorkouts") { () throws -> [[String: Any]] in
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 { plan in
323
+ return workouts.map { scheduled in
91
324
  return [
92
- "id": plan.id.uuidString,
93
- "date": self.dateComponentsToDict(plan.date)
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
- do {
109
- try await WorkoutScheduler.shared.remove(workout)
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
- 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
- }
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
- 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)
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
- do {
172
- try await composition.validate()
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 dateComponents = self.parseDateComponents(from: date)
178
- let plan = ScheduledWorkoutPlan(workout: composition, date: dateComponents)
403
+ let workout = SingleGoalWorkout(activity: activity, location: location, goal: goal)
404
+ let plan = WorkoutPlan(.goal(workout))
179
405
 
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
- }
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 workout = PacerWorkout(activity: activity, location: location, target: target, displayName: displayName)
205
- let composition = PacerWorkoutComposition(workout)
430
+ let (distance, time) = try self.parsePacerDistanceAndTime(from: targetConfig)
431
+ _ = PacerWorkout(activity: activity, location: location, distance: distance, time: time)
206
432
 
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
- }
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 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
- }
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
- 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
- }
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
- @unknown default:
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 .dance
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?) -> WorkoutLocationType? {
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 .outdoor
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 parsePacerTarget(from config: [String: Any]) throws -> PacerWorkout.Target {
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 unit = self.parseSpeedUnit(unitStr)
437
- return .speed(value, unit: unit)
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
- case "pace":
440
- let unitStr = config["unit"] as? String ?? "minutesPerKilometer"
441
- let unit = self.parsePaceUnit(unitStr)
442
- return .pace(time: value, unit: unit)
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
- private func parseWorkoutAlert(from config: [String: Any]) throws -> WorkoutAlert? {
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), unit: .beatsPerMinute)
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 .pace(min...max, unit: lengthUnit, metric: .current)
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, metric: .current)
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, unit: .stepsPerMinute, metric: .current)
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
- return .power(min...max, unit: .watts, metric: .current)
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
+ }