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.
@@ -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
+ }