craft-native 0.0.162 → 0.0.163

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.
@@ -791,28 +791,28 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
791
791
  } else {
792
792
  val totalRows = layout.rowHeights.sum()
793
793
  val rowGaps = max(0, layout.rowHeights.size - 1) * rowGap
794
- val freeRows = max(0, contentHeight - totalRows - rowGaps)
794
+ val freeRows = max(0, contentHeight - totalRows - rowGaps).toFloat()
795
795
  val rowCount = max(1, layout.rowHeights.size)
796
- var rowLeading = 0
797
- var rowBetween = rowGap
798
- var rowExtra = 0
796
+ var rowLeading = 0f
797
+ var rowBetween = rowGap.toFloat()
798
+ var rowExtra = 0f
799
799
  when (alignContentCode()) {
800
- 1 -> rowLeading = freeRows / 2
800
+ 1 -> rowLeading = freeRows / 2f
801
801
  2 -> rowLeading = freeRows
802
- 3 -> if (rowCount > 1) rowBetween += freeRows / (rowCount - 1)
802
+ 3 -> if (rowCount > 1) rowBetween += freeRows / (rowCount - 1).toFloat()
803
803
  4 -> {
804
- rowLeading = freeRows / (rowCount * 2)
805
- rowBetween += freeRows / rowCount
804
+ rowLeading = freeRows / (rowCount * 2).toFloat()
805
+ rowBetween += freeRows / rowCount.toFloat()
806
806
  }
807
807
  6 -> {
808
- rowLeading = freeRows / (rowCount + 1)
809
- rowBetween += freeRows / (rowCount + 1)
808
+ rowLeading = freeRows / (rowCount + 1).toFloat()
809
+ rowBetween += freeRows / (rowCount + 1).toFloat()
810
810
  }
811
- 5 -> rowExtra = freeRows / rowCount
811
+ 5 -> rowExtra = freeRows / rowCount.toFloat()
812
812
  }
813
- var rowTop = paddingTop + rowLeading
813
+ var rowTop = paddingTop.toFloat() + rowLeading
814
814
  for ((rowIndex, row) in layout.rows.withIndex()) {
815
- val rowHeight = layout.rowHeights[rowIndex] + rowExtra
815
+ val rowHeight = layout.rowHeights[rowIndex].toFloat() + rowExtra
816
816
  for ((column, entry) in row.withIndex()) {
817
817
  val align = entry.style.alignSelf ?: alignItems
818
818
  val lp = entry.view.layoutParams as? MarginLayoutParams
@@ -821,7 +821,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
821
821
  val marginTop = lp?.topMargin ?: 0
822
822
  val marginBottom = lp?.bottomMargin ?: 0
823
823
  val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
824
- val availableHeight = max(0, rowHeight - marginTop - marginBottom)
824
+ val availableHeight = max(0, (rowHeight - marginTop - marginBottom).roundToInt())
825
825
  val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
826
826
  val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
827
827
  val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
@@ -829,11 +829,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
829
829
  "flex-end" -> availableWidth - childWidth
830
830
  else -> 0
831
831
  }
832
- val y = rowTop + marginTop + when (align) {
832
+ val y = (rowTop + marginTop + when (align) {
833
833
  "center" -> (availableHeight - childHeight) / 2
834
834
  "flex-end" -> availableHeight - childHeight
835
835
  else -> 0
836
- }
836
+ }).roundToInt()
837
837
  entry.view.layout(x, y, x + childWidth, y + childHeight)
838
838
  }
839
839
  rowTop += rowHeight + rowBetween
@@ -930,8 +930,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
930
930
  }
931
931
  val free = max(0, availableMain - distributedMain)
932
932
  val (leading, between) = distribution(free, line.entries.size)
933
- var mainOffset = if (orientation == HORIZONTAL) paddingLeft else paddingTop
934
- mainOffset += leading
933
+ var mainOffset = (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + leading
935
934
  for ((index, entry) in line.entries.withIndex()) {
936
935
  val lp = entry.view.layoutParams as? MarginLayoutParams
937
936
  val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
@@ -958,10 +957,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
958
957
  // local main-axis rule remains the fallback for old runtimes
959
958
  // that return widths only.
960
959
  val sharedPosition = if (!reverse) sharedPositions?.get(index)?.roundToInt() else null
961
- val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop) + it }
960
+ val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + it }
962
961
  ?: mainOffset + marginStart
963
- val x = if (orientation == HORIZONTAL) positionedMain else lineOffset + crossPosition
964
- val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain
962
+ val x = if (orientation == HORIZONTAL) positionedMain.roundToInt() else lineOffset + crossPosition
963
+ val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain.roundToInt()
965
964
  val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
966
965
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
967
966
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
@@ -981,13 +980,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
981
980
  onLayoutChanged?.invoke()
982
981
  }
983
982
 
984
- private fun distribution(free: Int, count: Int): Pair<Int, Int> = when (justifyContent) {
985
- "center" -> free / 2 to 0
986
- "flex-end" -> free to 0
987
- "space-between" -> 0 to if (count > 1) free / (count - 1) else 0
988
- "space-around" -> free / (count * 2) to free / count
989
- "space-evenly" -> free / (count + 1) to free / (count + 1)
990
- else -> 0 to 0
983
+ private fun distribution(free: Int, count: Int): Pair<Float, Float> {
984
+ val available = free.toFloat()
985
+ return when (justifyContent) {
986
+ "center" -> available / 2f to 0f
987
+ "flex-end" -> available to 0f
988
+ "space-between" -> 0f to if (count > 1) available / (count - 1).toFloat() else 0f
989
+ "space-around" -> available / (count * 2).toFloat() to available / count.toFloat()
990
+ "space-evenly" -> available / (count + 1).toFloat() to available / (count + 1).toFloat()
991
+ else -> 0f to 0f
992
+ }
991
993
  }
992
994
  }
993
995
 
@@ -937,6 +937,142 @@ export declare const heartRate: {
937
937
  /** Bluetooth, scanning or the connection changed. */
938
938
  onState: (callback: (state: HeartRateState) => void) => (() => void);
939
939
  };
940
+ /**
941
+ * Structured workouts scheduled onto the paired Apple Watch (iOS 17+, an app
942
+ * built with `enableScheduledWorkouts`): each appears in the watch's Workout
943
+ * app on its day, with its steps, goals and alerts. Plain JSON in SI units.
944
+ * In a browser, on Android and in a build without the capability
945
+ * `isSupported()` answers false and every other call rejects with
946
+ * `unsupported` (code `UNSUPPORTED`).
947
+ */
948
+ export type ScheduledWorkoutsAuthorization = 'authorized' | 'denied' | 'notDetermined' | 'restricted' | 'unsupported';
949
+ export type AppleWorkoutGoal = {
950
+ type: 'open';
951
+ } | {
952
+ type: 'time';
953
+ seconds: number;
954
+ } | {
955
+ type: 'distance';
956
+ meters: number;
957
+ } | {
958
+ type: 'energy';
959
+ kilocalories: number;
960
+ };
961
+ export type AppleWorkoutAlert =
962
+ /** Beats per minute. */
963
+ {
964
+ type: 'heartRate';
965
+ min: number;
966
+ max: number;
967
+ } | {
968
+ type: 'heartRateZone';
969
+ zone: number;
970
+ }
971
+ /** Watts. */
972
+ | {
973
+ type: 'power';
974
+ min: number;
975
+ max: number;
976
+ } | {
977
+ type: 'powerZone';
978
+ zone: number;
979
+ }
980
+ /** Metres per second (the current speed); a pace range is sent as speed. */
981
+ | {
982
+ type: 'speed';
983
+ min: number;
984
+ max: number;
985
+ }
986
+ /** Steps or revolutions per minute. */
987
+ | {
988
+ type: 'cadence';
989
+ min: number;
990
+ max: number;
991
+ };
992
+ export interface AppleWorkoutStep {
993
+ goal: AppleWorkoutGoal;
994
+ /** Dropped (the step kept) when WorkoutKit refuses it for the activity. */
995
+ alert?: AppleWorkoutAlert;
996
+ /** Shown on the watch from iOS 18; ignored on 17. At most 64 characters. */
997
+ displayName?: string;
998
+ }
999
+ export interface AppleIntervalBlock {
1000
+ /** 1 or more. */
1001
+ iterations: number;
1002
+ steps: Array<{
1003
+ purpose: 'work' | 'recovery';
1004
+ step: AppleWorkoutStep;
1005
+ }>;
1006
+ }
1007
+ export interface AppleCustomWorkout {
1008
+ /** An HKWorkoutActivityType case name: 'running', 'cycling', 'swimming', 'traditionalStrengthTraining'... */
1009
+ activity: string;
1010
+ location: 'indoor' | 'outdoor' | 'unknown';
1011
+ /** Swimming only: a pool swim is scheduled indoor, open water outdoor, when `location` is 'unknown'. */
1012
+ swimmingLocation?: 'pool' | 'openWater';
1013
+ /** Accepted for the record; WorkoutKit takes no pool length (the watch asks when the swim starts). */
1014
+ poolLengthMeters?: number;
1015
+ displayName: string;
1016
+ warmup?: AppleWorkoutStep;
1017
+ cooldown?: AppleWorkoutStep;
1018
+ blocks: AppleIntervalBlock[];
1019
+ }
1020
+ export interface AppleWorkoutPlan {
1021
+ /** A UUID; the plan's identity on the watch. */
1022
+ id: string;
1023
+ /** The local calendar day, 'YYYY-MM-DD'. */
1024
+ date: string;
1025
+ /** Local time of day, 07:00 when not given. */
1026
+ hour?: number;
1027
+ minute?: number;
1028
+ workout: AppleCustomWorkout;
1029
+ }
1030
+ /** A plan on the watch's schedule. */
1031
+ export interface ScheduledWorkout {
1032
+ /** Lowercase UUID. */
1033
+ id: string;
1034
+ date: string;
1035
+ hour?: number;
1036
+ minute?: number;
1037
+ /** The person has done it. */
1038
+ complete: boolean;
1039
+ }
1040
+ export interface ScheduleWorkoutResult {
1041
+ id: string;
1042
+ /** Alerts WorkoutKit refused and left out, each by where it was ("block 1 step 2: power alert..."). */
1043
+ droppedAlerts?: string[];
1044
+ }
1045
+ export type ScheduledWorkoutsMethod = 'isSupported' | 'authorization' | 'requestAuthorization' | 'list' | 'schedule' | 'remove' | 'removeAll';
1046
+ /** The rejection every call but isSupported() gets where nothing can schedule. */
1047
+ export declare function scheduledWorkoutsUnsupported(): Error;
1048
+ /** A row of `list()`, or null for one that is not a scheduled plan. */
1049
+ export declare function normalizeScheduledWorkout(value: unknown): ScheduledWorkout | null;
1050
+ export declare const scheduledWorkouts: {
1051
+ /** Whether this app build has the bridge at all (not whether this phone can schedule: `isSupported()`). */
1052
+ isAvailable(): boolean;
1053
+ /** Whether this app build answers `method`; an older build may lack one. */
1054
+ supports(method: ScheduledWorkoutsMethod): boolean;
1055
+ /** iOS 17 or later and WorkoutKit can schedule here; false everywhere else, never rejects. */
1056
+ isSupported(): Promise<boolean>;
1057
+ /** What the person allowed, without asking. */
1058
+ authorization: () => Promise<ScheduledWorkoutsAuthorization>;
1059
+ /** iOS's prompt the first time; its remembered answer after that. Call it from the person's tap. */
1060
+ requestAuthorization: () => Promise<ScheduledWorkoutsAuthorization>;
1061
+ /** Every plan on the watch's schedule. */
1062
+ list: () => Promise<ScheduledWorkout[]>;
1063
+ /**
1064
+ * Put a plan on the watch's schedule (again on another day moves it).
1065
+ * Rejects with a message naming what WorkoutKit refused: an activity or a
1066
+ * goal it cannot schedule (an alert it refuses is dropped instead, listed in
1067
+ * `droppedAlerts`), more plans than the watch holds (LIMIT_REACHED), or a
1068
+ * schedule nothing kept, such as no paired watch (NOT_SCHEDULED).
1069
+ */
1070
+ schedule: (plan: AppleWorkoutPlan) => Promise<ScheduleWorkoutResult>;
1071
+ /** Take a plan off every day it is scheduled; true when there was one. */
1072
+ remove: (id: string) => Promise<boolean>;
1073
+ /** Clear the schedule; how many plans it held. */
1074
+ removeAll: () => Promise<number>;
1075
+ };
940
1076
  export declare const deepLinks: {
941
1077
  getInitialURL(): Promise<string | null>;
942
1078
  /**
@@ -1474,6 +1610,7 @@ declare const mobile: {
1474
1610
  speech: typeof speech;
1475
1611
  music: typeof music;
1476
1612
  heartRate: typeof heartRate;
1613
+ scheduledWorkouts: typeof scheduledWorkouts;
1477
1614
  deepLinks: typeof deepLinks;
1478
1615
  network: typeof network;
1479
1616
  appReview: typeof appReview;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.162";
3306
+ var version = "0.0.163";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.162";
313
+ var version = "0.0.163";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.162";
61
+ var version = "0.0.163";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -88,6 +88,17 @@ export interface CraftConfig {
88
88
  * playback session).
89
89
  */
90
90
  enableBackgroundAudio?: boolean;
91
+ /**
92
+ * Schedule structured workouts onto the paired Apple Watch through
93
+ * `craft.scheduledWorkouts` (WorkoutKit's WorkoutScheduler, iOS 17+): each
94
+ * appears in the watch's Workout app on its day, with its steps, goals and
95
+ * alerts. Compiles CraftScheduledWorkouts.swift with the
96
+ * `CRAFT_SCHEDULED_WORKOUTS` condition, so an app without it never links
97
+ * WorkoutKit; below iOS 17 it is weak-linked and answers unsupported. iOS
98
+ * asks the person the first time the page calls
99
+ * `craft.scheduledWorkouts.requestAuthorization()`, never at launch.
100
+ */
101
+ enableScheduledWorkouts?: boolean;
91
102
  /**
92
103
  * Purpose strings in the app's own words, by Info.plist key
93
104
  * (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
@@ -381,6 +392,13 @@ export declare function renderWatchEntitlements(config: CraftConfig): string;
381
392
  * reach everything the bridge actually uses.
382
393
  */
383
394
  export declare function renderRuntimeSettings(): string;
395
+ /**
396
+ * Build settings a feature adds to the app target. WorkoutKit is compiled in
397
+ * only for `enableScheduledWorkouts`: CraftScheduledWorkouts.swift imports it
398
+ * behind the `CRAFT_SCHEDULED_WORKOUTS` condition, so an app that does not
399
+ * schedule workouts does not link it. `$(inherited)` keeps Debug's `DEBUG`.
400
+ */
401
+ export declare function renderFeatureSettings(config: Pick<CraftConfig, 'enableScheduledWorkouts'>): string;
384
402
  export declare function renderPrivacyManifest(config: CraftConfig): string;
385
403
  /**
386
404
  * Whether a PNG carries an alpha channel, from its header: colour type 4
@@ -349,6 +349,7 @@ var DEFAULT_CONFIG = {
349
349
  enableMLKit: false,
350
350
  enableAppleMusic: false,
351
351
  enableHeartRate: false,
352
+ enableScheduledWorkouts: false,
352
353
  iosVersion: "16.0",
353
354
  watchosVersion: "9.0",
354
355
  teamId: "",
@@ -583,6 +584,14 @@ function renderRuntimeSettings() {
583
584
  ].join(`
584
585
  `);
585
586
  }
587
+ function renderFeatureSettings(config) {
588
+ const conditions = [];
589
+ if (config.enableScheduledWorkouts)
590
+ conditions.push("CRAFT_SCHEDULED_WORKOUTS");
591
+ if (!conditions.length)
592
+ return "";
593
+ return ` SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) ${conditions.join(" ")}"`;
594
+ }
586
595
  function renderPrivacyManifest(config) {
587
596
  const privacy = config.privacy ?? {};
588
597
  const collected = privacy.collectedDataTypes ?? [];
@@ -944,6 +953,7 @@ function writeShell(output, config) {
944
953
  cpSync(join2(TEMPLATES_DIR, "CraftHealth.swift"), join2(output, "Sources", "CraftHealth.swift"));
945
954
  cpSync(join2(TEMPLATES_DIR, "CraftMusic.swift"), join2(output, "Sources", "CraftMusic.swift"));
946
955
  cpSync(join2(TEMPLATES_DIR, "CraftHeartRate.swift"), join2(output, "Sources", "CraftHeartRate.swift"));
956
+ cpSync(join2(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join2(output, "Sources", "CraftScheduledWorkouts.swift"));
947
957
  cpSync(join2(TEMPLATES_DIR, "CraftNativeMutation.swift"), join2(output, "Sources", "CraftNativeMutation.swift"));
948
958
  cpSync(join2(TEMPLATES_DIR, "CraftNativeUI.swift"), join2(output, "Sources", "CraftNativeUI.swift"));
949
959
  cpSync(join2(TEMPLATES_DIR, "CraftNativeServices.swift"), join2(output, "Sources", "CraftNativeServices.swift"));
@@ -1065,7 +1075,7 @@ async function init(options) {
1065
1075
  } else {
1066
1076
  rmSync2(join2(output, "Runtime"), { recursive: true, force: true });
1067
1077
  }
1068
- const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
1078
+ const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{FEATURE_SETTINGS\}\}/g, renderFeatureSettings(config)).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
1069
1079
  ${nativeDependencies.join(`
1070
1080
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
1071
1081
  `));
@@ -1298,6 +1308,7 @@ export {
1298
1308
  renderOrientations,
1299
1309
  renderOfflinePage,
1300
1310
  renderLaunchScreen,
1311
+ renderFeatureSettings,
1301
1312
  renderEntitlements,
1302
1313
  renderDeviceFamilies,
1303
1314
  renderBackgroundTaskIdentifiers,
@@ -1212,6 +1212,9 @@ struct CraftConfig: Codable {
1212
1212
  /// A Bluetooth heart-rate strap (or a watch broadcasting its heart rate)
1213
1213
  /// through `craft.heartRate` (CraftHeartRate.swift).
1214
1214
  var enableHeartRate: Bool = false
1215
+ /// Structured workouts scheduled onto the paired Apple Watch through
1216
+ /// `craft.scheduledWorkouts` (CraftScheduledWorkouts.swift, WorkoutKit).
1217
+ var enableScheduledWorkouts: Bool = false
1215
1218
  var devServerURL: String? = nil
1216
1219
  var trustedOrigins: [String] = []
1217
1220
  /// The longest the launch splash stays up when the page neither says it is
@@ -2335,6 +2338,22 @@ struct CraftWebView: UIViewRepresentable {
2335
2338
  }
2336
2339
  }
2337
2340
  }
2341
+ // MARK: - Scheduled workouts (CraftScheduledWorkouts.swift)
2342
+ // Gated on enableScheduledWorkouts. One action for every method,
2343
+ // as a native screen's ScheduledWorkouts module calls them;
2344
+ // asking to schedule has its own, which waits on the person.
2345
+ case "scheduledWorkouts", "scheduledWorkoutsRequestAuthorization":
2346
+ if !config.enableScheduledWorkouts {
2347
+ rejectCallback(callbackId, error: "Scheduled workouts are disabled", code: "CAPABILITY_DISABLED")
2348
+ } else {
2349
+ let method = action == "scheduledWorkouts" ? body["method"] as? String ?? "" : "requestAuthorization"
2350
+ CraftScheduledWorkouts.shared.perform(method: method, args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
2351
+ switch result {
2352
+ case .success(let value): self?.resolveCallback(callbackId, result: value)
2353
+ case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
2354
+ }
2355
+ }
2356
+ }
2338
2357
  // MARK: - Orientation Lock
2339
2358
  case "lockOrientation":
2340
2359
  if config.enableOrientationLock {
@@ -3337,6 +3356,7 @@ struct CraftWebView: UIViewRepresentable {
3337
3356
  brightness: true,
3338
3357
  music: \(config.enableAppleMusic),
3339
3358
  heartRate: \(config.enableHeartRate),
3359
+ scheduledWorkouts: \(config.enableScheduledWorkouts),
3340
3360
  speech: true,
3341
3361
  network: true,
3342
3362
  deviceInfo: true,
@@ -4877,6 +4897,7 @@ struct CraftWebView: UIViewRepresentable {
4877
4897
  requestHealthAuthorization: true,
4878
4898
  musicRequestAuthorization: true,
4879
4899
  heartRateRequestAuthorization: true,
4900
+ scheduledWorkoutsRequestAuthorization: true,
4880
4901
  requestPermission: true,
4881
4902
  signInWithApple: true,
4882
4903
  dialogAlert: true,
@@ -5123,6 +5144,22 @@ struct CraftWebView: UIViewRepresentable {
5123
5144
  readRecording: function(options) { return heartRate('readRecording', {since: options && typeof options.since === 'number' ? options.since : null}); }
5124
5145
  };
5125
5146
  })();
5147
+ // Structured workouts on the paired Apple Watch (WorkoutKit,
5148
+ // iOS 17+): each appears in its Workout app on its day.
5149
+ // schedule(plan) answers {id} or rejects naming what WorkoutKit
5150
+ // refused; list() answers [{id, date, hour, minute, complete}].
5151
+ (function() {
5152
+ function scheduled(method, options) { return craft._invoke('scheduledWorkouts', {method: method, options: options || {}}); }
5153
+ craft.scheduledWorkouts = {
5154
+ isSupported: function() { return scheduled('isSupported'); },
5155
+ authorization: function() { return scheduled('authorization'); },
5156
+ requestAuthorization: function() { return craft._invoke('scheduledWorkoutsRequestAuthorization'); },
5157
+ list: function() { return scheduled('list'); },
5158
+ schedule: function(plan) { return scheduled('schedule', plan); },
5159
+ remove: function(id) { return scheduled('remove', {id: typeof id === 'string' ? id : id && id.id}); },
5160
+ removeAll: function() { return scheduled('removeAll'); }
5161
+ };
5162
+ })();
5126
5163
  craft.liveActivity = {
5127
5164
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
5128
5165
  update: function(idOrState, state) {
@@ -68,6 +68,9 @@ enum CraftNativeActions {
68
68
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
69
69
  // so it needs no web view.
70
70
  if config.enableHeartRate { values.append("heartRate") }
71
+ // Workouts on the paired Apple Watch (CraftScheduledWorkouts.swift),
72
+ // also the app's own service.
73
+ if config.enableScheduledWorkouts { values.append("scheduledWorkouts") }
71
74
  // Photos picked from the library, files downloaded for the share
72
75
  // sheet and sent in a form (CraftNativeFiles.swift): the photo picker
73
76
  // runs out of process and asks no permission. Taking a photo needs
@@ -338,6 +341,17 @@ enum CraftNativeActions {
338
341
  complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
339
342
  }
340
343
  return
344
+ case ("ScheduledWorkouts", _):
345
+ // Workouts on the paired Apple Watch, the same service as a
346
+ // page's `craft.scheduledWorkouts` (WorkoutKit's scheduler).
347
+ guard config.enableScheduledWorkouts else {
348
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Scheduled workouts are disabled"), completion)
349
+ return
350
+ }
351
+ CraftScheduledWorkouts.shared.perform(method: method, args: args) { result in
352
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
353
+ }
354
+ return
341
355
  case ("Linking", "openURL"), ("Linking", "canOpenURL"):
342
356
  // Another app or the system: `webcal:` subscriptions, `mailto:`,
343
357
  // `tel:`, the App Store, this app's page in Settings
@@ -1178,6 +1178,7 @@ private class CraftNativeFlowView: UIStackView {
1178
1178
  case "flex-end": 2
1179
1179
  case "space-between": 3
1180
1180
  case "space-around": 4
1181
+ case "space-evenly": 6
1181
1182
  case "stretch": 5
1182
1183
  default: 0
1183
1184
  }
@@ -1374,6 +1375,9 @@ private class CraftNativeFlowView: UIStackView {
1374
1375
  case "space-around":
1375
1376
  rowLeading = gridFree / CGFloat(rowHeights.count * 2)
1376
1377
  rowBetween += gridFree / CGFloat(rowHeights.count)
1378
+ case "space-evenly":
1379
+ rowLeading = gridFree / CGFloat(rowHeights.count + 1)
1380
+ rowBetween += gridFree / CGFloat(rowHeights.count + 1)
1377
1381
  case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
1378
1382
  default: break
1379
1383
  }
@@ -0,0 +1,524 @@
1
+ import Foundation
2
+ #if CRAFT_SCHEDULED_WORKOUTS
3
+ import HealthKit
4
+ import WorkoutKit
5
+ #endif
6
+
7
+ /// A scheduling call that did not happen: the message a page sees, and the
8
+ /// bridge's error code.
9
+ struct CraftScheduledWorkoutsError: Error {
10
+ let message: String
11
+ var code: String = "CRAFT_ERROR"
12
+ }
13
+
14
+ /// Structured workouts scheduled onto the paired Apple Watch through
15
+ /// WorkoutKit's `WorkoutScheduler` (iOS 17+): each appears in the watch's
16
+ /// Workout app on its day, steps, targets and alerts included.
17
+ ///
18
+ /// One service for both sides: the page's `craft.scheduledWorkouts` (actions
19
+ /// `scheduledWorkouts` and `scheduledWorkoutsRequestAuthorization`, which
20
+ /// waits on the person) and a native screen's `ScheduledWorkouts` module.
21
+ ///
22
+ /// WorkoutKit is compiled in only with `enableScheduledWorkouts`, which sets
23
+ /// the `CRAFT_SCHEDULED_WORKOUTS` condition, so an app that does not schedule
24
+ /// workouts does not link it. Without it (or below iOS 17) `isSupported`
25
+ /// answers false, `authorization` answers `unsupported`, and everything else
26
+ /// rejects UNSUPPORTED.
27
+ ///
28
+ /// A plan is plain JSON (SI units):
29
+ ///
30
+ /// { id: UUID, date: 'YYYY-MM-DD', hour?, minute?, workout: {
31
+ /// activity: HKWorkoutActivityType case name, location: 'indoor' | 'outdoor' | 'unknown',
32
+ /// swimmingLocation?: 'pool' | 'openWater', poolLengthMeters?, displayName,
33
+ /// warmup?: step, cooldown?: step,
34
+ /// blocks: [{ iterations, steps: [{ purpose: 'work' | 'recovery', step }] }] } }
35
+ /// step = { goal: { type: 'open' | 'time' (seconds) | 'distance' (meters) | 'energy' (kilocalories) },
36
+ /// alert?: { type: 'heartRate' | 'power' | 'speed' | 'cadence', min, max } | { type: 'heartRateZone' | 'powerZone', zone },
37
+ /// displayName? }
38
+ ///
39
+ /// An activity or a goal WorkoutKit refuses rejects the call, naming it; an
40
+ /// alert it refuses is dropped and its step kept (`droppedAlerts`).
41
+ ///
42
+ /// Simulator builds can stand in a scheduler with the
43
+ /// `CraftFakeWorkoutScheduler` default (`xcrun simctl spawn booted defaults
44
+ /// write <bundle id> CraftFakeWorkoutScheduler -bool YES`): plans are still
45
+ /// built and checked by WorkoutKit, then kept in UserDefaults instead of on a
46
+ /// watch, which a simulator never has. `CraftFakeWorkoutSchedulerAuthorization`
47
+ /// (a string) presets its answer to `denied`, `restricted` or `authorized`.
48
+ final class CraftScheduledWorkouts {
49
+ typealias Completion = (Result<Any, CraftScheduledWorkoutsError>) -> Void
50
+
51
+ static let shared = CraftScheduledWorkouts()
52
+
53
+ /// The call by method, with its options as the bridge sends them; answered on main.
54
+ func perform(method: String, args: [Any], completion: @escaping Completion) {
55
+ let options = args.first as? [String: Any] ?? [:]
56
+ let done: Completion = { result in DispatchQueue.main.async { completion(result) } }
57
+ #if CRAFT_SCHEDULED_WORKOUTS
58
+ if #available(iOS 17.0, *) {
59
+ Task { done(await CraftWorkoutKitScheduler.shared.perform(method: method, options: options)) }
60
+ return
61
+ }
62
+ #endif
63
+ done(Self.unsupported(method: method))
64
+ }
65
+
66
+ /// The answer where WorkoutKit is not there: compiled out, or below iOS 17.
67
+ static func unsupported(method: String) -> Result<Any, CraftScheduledWorkoutsError> {
68
+ switch method {
69
+ case "isSupported": return .success(false)
70
+ case "authorization", "requestAuthorization": return .success("unsupported")
71
+ case "list", "schedule", "remove", "removeAll":
72
+ return .failure(CraftScheduledWorkoutsError(message: "unsupported", code: "UNSUPPORTED"))
73
+ default:
74
+ return .failure(CraftScheduledWorkoutsError(message: "Unsupported ScheduledWorkouts method: \(method)", code: "UNKNOWN_ACTION"))
75
+ }
76
+ }
77
+ }
78
+
79
+ #if CRAFT_SCHEDULED_WORKOUTS
80
+
81
+ /// What the scheduler keeps: a plan's id on a day, and whether it was done.
82
+ @available(iOS 17.0, *)
83
+ struct CraftScheduledWorkoutEntry {
84
+ let id: UUID
85
+ let date: DateComponents
86
+ let complete: Bool
87
+ /// The plan itself, which WorkoutKit needs to remove it; nil from the stand-in.
88
+ let plan: WorkoutPlan?
89
+
90
+ /// `{ id, date, hour?, minute?, complete }`, the id lowercase.
91
+ var json: [String: Any] {
92
+ var out: [String: Any] = ["id": id.uuidString.lowercased(), "complete": complete]
93
+ if let year = date.year, let month = date.month, let day = date.day {
94
+ out["date"] = String(format: "%04d-%02d-%02d", year, month, day)
95
+ }
96
+ if let hour = date.hour { out["hour"] = hour }
97
+ if let minute = date.minute { out["minute"] = minute }
98
+ return out
99
+ }
100
+ }
101
+
102
+ /// WorkoutKit's scheduler, or the simulator's stand-in.
103
+ @available(iOS 17.0, *)
104
+ protocol CraftWorkoutStore: AnyObject {
105
+ var isSupported: Bool { get }
106
+ var maxCount: Int { get }
107
+ func authorization() async -> String
108
+ func requestAuthorization() async -> String
109
+ func entries() async -> [CraftScheduledWorkoutEntry]
110
+ func schedule(_ plan: WorkoutPlan, at date: DateComponents) async
111
+ func remove(_ entry: CraftScheduledWorkoutEntry) async
112
+ func removeAll() async
113
+ }
114
+
115
+ @available(iOS 17.0, *)
116
+ func craftWorkoutAuthorization(_ state: WorkoutScheduler.AuthorizationState) -> String {
117
+ switch state {
118
+ case .authorized: return "authorized"
119
+ case .denied: return "denied"
120
+ case .restricted: return "restricted"
121
+ case .notDetermined: return "notDetermined"
122
+ @unknown default: return "notDetermined"
123
+ }
124
+ }
125
+
126
+ @available(iOS 17.0, *)
127
+ final class CraftSystemWorkoutStore: CraftWorkoutStore {
128
+ var isSupported: Bool { WorkoutScheduler.isSupported }
129
+ var maxCount: Int { WorkoutScheduler.maxAllowedScheduledWorkoutCount }
130
+ func authorization() async -> String { craftWorkoutAuthorization(await WorkoutScheduler.shared.authorizationState) }
131
+ func requestAuthorization() async -> String { craftWorkoutAuthorization(await WorkoutScheduler.shared.requestAuthorization()) }
132
+ func entries() async -> [CraftScheduledWorkoutEntry] {
133
+ await WorkoutScheduler.shared.scheduledWorkouts.map {
134
+ CraftScheduledWorkoutEntry(id: $0.plan.id, date: $0.date, complete: $0.complete, plan: $0.plan)
135
+ }
136
+ }
137
+ func schedule(_ plan: WorkoutPlan, at date: DateComponents) async { await WorkoutScheduler.shared.schedule(plan, at: date) }
138
+ func remove(_ entry: CraftScheduledWorkoutEntry) async {
139
+ guard let plan = entry.plan else { return }
140
+ await WorkoutScheduler.shared.remove(plan, at: entry.date)
141
+ }
142
+ func removeAll() async { await WorkoutScheduler.shared.removeAllWorkouts() }
143
+ }
144
+
145
+ /// Simulator builds only (see `CraftScheduledWorkouts`): plans kept in
146
+ /// UserDefaults, as a paired watch would keep them.
147
+ @available(iOS 17.0, *)
148
+ final class CraftFakeWorkoutStore: CraftWorkoutStore {
149
+ private let key = "CraftFakeScheduledWorkouts"
150
+ private let authorizationKey = "CraftFakeWorkoutSchedulerAuthorization"
151
+ private let defaults = UserDefaults.standard
152
+ let isSupported = true
153
+ var maxCount: Int { WorkoutScheduler.maxAllowedScheduledWorkoutCount }
154
+
155
+ func authorization() async -> String { defaults.string(forKey: authorizationKey) ?? "notDetermined" }
156
+ func requestAuthorization() async -> String {
157
+ let current = await authorization()
158
+ guard current == "notDetermined" else { return current }
159
+ defaults.set("authorized", forKey: authorizationKey)
160
+ return "authorized"
161
+ }
162
+ func entries() async -> [CraftScheduledWorkoutEntry] {
163
+ (defaults.array(forKey: key) as? [[String: Any]] ?? []).compactMap { row in
164
+ guard let id = (row["id"] as? String).flatMap(UUID.init(uuidString:)) else { return nil }
165
+ var date = DateComponents()
166
+ date.year = row["year"] as? Int
167
+ date.month = row["month"] as? Int
168
+ date.day = row["day"] as? Int
169
+ date.hour = row["hour"] as? Int
170
+ date.minute = row["minute"] as? Int
171
+ return CraftScheduledWorkoutEntry(id: id, date: date, complete: row["complete"] as? Bool ?? false, plan: nil)
172
+ }
173
+ }
174
+ func schedule(_ plan: WorkoutPlan, at date: DateComponents) async {
175
+ var rows = defaults.array(forKey: key) as? [[String: Any]] ?? []
176
+ var row: [String: Any] = ["id": plan.id.uuidString, "complete": false]
177
+ row["year"] = date.year
178
+ row["month"] = date.month
179
+ row["day"] = date.day
180
+ row["hour"] = date.hour
181
+ row["minute"] = date.minute
182
+ rows.append(row)
183
+ defaults.set(rows, forKey: key)
184
+ }
185
+ func remove(_ entry: CraftScheduledWorkoutEntry) async {
186
+ let rows = (defaults.array(forKey: key) as? [[String: Any]] ?? []).filter { row in
187
+ !((row["id"] as? String).flatMap(UUID.init(uuidString:)) == entry.id
188
+ && row["year"] as? Int == entry.date.year && row["month"] as? Int == entry.date.month
189
+ && row["day"] as? Int == entry.date.day && row["hour"] as? Int == entry.date.hour
190
+ && row["minute"] as? Int == entry.date.minute)
191
+ }
192
+ defaults.set(rows, forKey: key)
193
+ }
194
+ func removeAll() async { defaults.removeObject(forKey: key) }
195
+ }
196
+
197
+ /// The calls, on whichever store this build schedules with.
198
+ @available(iOS 17.0, *)
199
+ final class CraftWorkoutKitScheduler {
200
+ static let shared = CraftWorkoutKitScheduler(store: CraftWorkoutKitScheduler.makeStore())
201
+
202
+ private let store: CraftWorkoutStore
203
+
204
+ init(store: CraftWorkoutStore) { self.store = store }
205
+
206
+ private static func makeStore() -> CraftWorkoutStore {
207
+ #if targetEnvironment(simulator)
208
+ if UserDefaults.standard.bool(forKey: "CraftFakeWorkoutScheduler") { return CraftFakeWorkoutStore() }
209
+ #endif
210
+ return CraftSystemWorkoutStore()
211
+ }
212
+
213
+ private func failure(_ message: String, _ code: String) -> Result<Any, CraftScheduledWorkoutsError> {
214
+ .failure(CraftScheduledWorkoutsError(message: message, code: code))
215
+ }
216
+
217
+ func perform(method: String, options: [String: Any]) async -> Result<Any, CraftScheduledWorkoutsError> {
218
+ switch method {
219
+ case "isSupported":
220
+ return .success(store.isSupported)
221
+ case "authorization":
222
+ return .success(store.isSupported ? await store.authorization() : "unsupported")
223
+ case "requestAuthorization":
224
+ return .success(store.isSupported ? await store.requestAuthorization() : "unsupported")
225
+ case "list", "schedule", "remove", "removeAll":
226
+ guard store.isSupported else { return failure("unsupported", "UNSUPPORTED") }
227
+ guard await store.authorization() == "authorized" else {
228
+ return failure("Scheduling workouts on the Apple Watch has not been allowed", "NOT_AUTHORIZED")
229
+ }
230
+ switch method {
231
+ case "list": return .success(await store.entries().map(\.json))
232
+ case "schedule": return await schedule(options)
233
+ case "remove": return await remove(id: options["id"] as? String)
234
+ default:
235
+ let count = await store.entries().count
236
+ await store.removeAll()
237
+ return .success(count)
238
+ }
239
+ default:
240
+ return failure("Unsupported ScheduledWorkouts method: \(method)", "UNKNOWN_ACTION")
241
+ }
242
+ }
243
+
244
+ private func schedule(_ body: [String: Any]) async -> Result<Any, CraftScheduledWorkoutsError> {
245
+ let built: CraftWorkoutPlanBuilder.Built
246
+ switch CraftWorkoutPlanBuilder.build(body) {
247
+ case .success(let value): built = value
248
+ case .failure(let error): return .failure(error)
249
+ }
250
+ let existing = await store.entries().filter { $0.id == built.plan.id }
251
+ let sameDay = existing.contains { CraftWorkoutPlanBuilder.sameMoment($0.date, built.date) }
252
+ if !sameDay {
253
+ // A plan id lives on one day: scheduling it again moves it.
254
+ let others = await store.entries().count - existing.count
255
+ guard others < store.maxCount else {
256
+ return failure("The Apple Watch holds at most \(store.maxCount) scheduled workouts; remove one first", "LIMIT_REACHED")
257
+ }
258
+ for entry in existing { await store.remove(entry) }
259
+ await store.schedule(built.plan, at: built.date)
260
+ // WorkoutScheduler.schedule neither throws nor answers; what it
261
+ // kept is the answer (nothing, without a paired watch).
262
+ let kept = await store.entries().contains { $0.id == built.plan.id && CraftWorkoutPlanBuilder.sameMoment($0.date, built.date) }
263
+ guard kept else {
264
+ return failure("WorkoutKit did not keep the workout. Is an Apple Watch paired with the Workout app installed?", "NOT_SCHEDULED")
265
+ }
266
+ }
267
+ var out: [String: Any] = ["id": built.plan.id.uuidString.lowercased()]
268
+ if !built.droppedAlerts.isEmpty { out["droppedAlerts"] = built.droppedAlerts }
269
+ return .success(out)
270
+ }
271
+
272
+ private func remove(id: String?) async -> Result<Any, CraftScheduledWorkoutsError> {
273
+ guard let id, let uuid = UUID(uuidString: id) else {
274
+ return failure("remove needs a plan id (a UUID)", "INVALID_ARGUMENT")
275
+ }
276
+ let matching = await store.entries().filter { $0.id == uuid }
277
+ for entry in matching { await store.remove(entry) }
278
+ return .success(!matching.isEmpty)
279
+ }
280
+ }
281
+
282
+ /// A plan's JSON turned into WorkoutKit's types, checked the way WorkoutKit
283
+ /// checks them (`CustomWorkout.supportsActivity`, `supportsGoal`,
284
+ /// `supportsAlert`). Pure: no scheduler, so it is unit tested on its own.
285
+ @available(iOS 17.0, *)
286
+ enum CraftWorkoutPlanBuilder {
287
+ struct Built {
288
+ let plan: WorkoutPlan
289
+ let date: DateComponents
290
+ /// Each alert WorkoutKit refused, by where it was ("block 1 step 2: power").
291
+ let droppedAlerts: [String]
292
+ }
293
+
294
+ static let activities: [String: HKWorkoutActivityType] = [
295
+ "running": .running, "cycling": .cycling, "swimming": .swimming, "walking": .walking,
296
+ "hiking": .hiking, "traditionalStrengthTraining": .traditionalStrengthTraining,
297
+ "functionalStrengthTraining": .functionalStrengthTraining, "rowing": .rowing, "yoga": .yoga,
298
+ "flexibility": .flexibility, "crossTraining": .crossTraining, "elliptical": .elliptical,
299
+ "highIntensityIntervalTraining": .highIntensityIntervalTraining, "other": .other,
300
+ "coreTraining": .coreTraining, "mixedCardio": .mixedCardio, "stairClimbing": .stairClimbing,
301
+ "stairs": .stairs, "stepTraining": .stepTraining, "pilates": .pilates, "cardioDance": .cardioDance,
302
+ "kickboxing": .kickboxing, "boxing": .boxing, "jumpRope": .jumpRope, "cooldown": .cooldown,
303
+ "crossCountrySkiing": .crossCountrySkiing, "paddleSports": .paddleSports,
304
+ "handCycling": .handCycling, "wheelchairRunPace": .wheelchairRunPace, "wheelchairWalkPace": .wheelchairWalkPace,
305
+ ]
306
+
307
+ static let locations: [String: HKWorkoutSessionLocationType] = ["indoor": .indoor, "outdoor": .outdoor, "unknown": .unknown]
308
+
309
+ private static func refuse(_ message: String) -> Result<Built, CraftScheduledWorkoutsError> {
310
+ .failure(CraftScheduledWorkoutsError(message: message, code: "INVALID_ARGUMENT"))
311
+ }
312
+
313
+ private static func number(_ value: Any?) -> Double? {
314
+ // JSON's true and false arrive as NSNumber too; they are not numbers here.
315
+ guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID() else { return nil }
316
+ let double = value.doubleValue
317
+ return double.isFinite ? double : nil
318
+ }
319
+
320
+ /// Two schedule dates as WorkoutKit compares them: the day and the time of day.
321
+ static func sameMoment(_ a: DateComponents, _ b: DateComponents) -> Bool {
322
+ a.year == b.year && a.month == b.month && a.day == b.day && (a.hour ?? 0) == (b.hour ?? 0) && (a.minute ?? 0) == (b.minute ?? 0)
323
+ }
324
+
325
+ /// 'YYYY-MM-DD' plus the time of day (07:00 when not given), local.
326
+ static func date(_ body: [String: Any]) -> DateComponents? {
327
+ guard let text = body["date"] as? String else { return nil }
328
+ let parts = text.split(separator: "-", omittingEmptySubsequences: false)
329
+ guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
330
+ let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil }
331
+ let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 : number(body["hour"]).map { Int($0) }
332
+ let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : number(body["minute"]).map { Int($0) }
333
+ guard let hour, let minute, (0...23).contains(hour), (0...59).contains(minute) else { return nil }
334
+ var components = DateComponents()
335
+ components.year = year
336
+ components.month = month
337
+ components.day = day
338
+ // A day that does not exist (2026-02-30) is refused, not rolled over.
339
+ var calendar = Calendar(identifier: .gregorian)
340
+ calendar.timeZone = .current
341
+ guard let real = calendar.date(from: components),
342
+ calendar.component(.day, from: real) == day, calendar.component(.month, from: real) == month else { return nil }
343
+ components.hour = hour
344
+ components.minute = minute
345
+ return components
346
+ }
347
+
348
+ static func goal(_ value: Any?) -> Result<(goal: WorkoutGoal, name: String), CraftScheduledWorkoutsError> {
349
+ let body = value as? [String: Any] ?? [:]
350
+ let type = body["type"] as? String ?? (value == nil ? "open" : "")
351
+ func positive(_ key: String) -> Double? { number(body[key]).flatMap { $0 > 0 ? $0 : nil } }
352
+ switch type {
353
+ case "open": return .success((.open, "open"))
354
+ case "time":
355
+ guard let seconds = positive("seconds") else { break }
356
+ return .success((.time(seconds, .seconds), "time"))
357
+ case "distance":
358
+ guard let meters = positive("meters") else { break }
359
+ return .success((.distance(meters, .meters), "distance"))
360
+ case "energy":
361
+ guard let kilocalories = positive("kilocalories") else { break }
362
+ return .success((.energy(kilocalories, .kilocalories), "energy"))
363
+ default:
364
+ return .failure(CraftScheduledWorkoutsError(message: "Unknown goal type '\(type)'", code: "INVALID_ARGUMENT"))
365
+ }
366
+ return .failure(CraftScheduledWorkoutsError(message: "A \(type) goal needs a positive number", code: "INVALID_ARGUMENT"))
367
+ }
368
+
369
+ /// The alert, or why it was left out.
370
+ static func alert(_ value: Any?) -> Result<(alert: any WorkoutAlert, name: String), CraftScheduledWorkoutsError> {
371
+ let body = value as? [String: Any] ?? [:]
372
+ let type = body["type"] as? String ?? ""
373
+ func invalid(_ why: String) -> Result<(alert: any WorkoutAlert, name: String), CraftScheduledWorkoutsError> {
374
+ .failure(CraftScheduledWorkoutsError(message: "\(type.isEmpty ? "alert" : type) \(why)", code: "INVALID_ARGUMENT"))
375
+ }
376
+ func range() -> ClosedRange<Double>? {
377
+ guard let a = number(body["min"]), let b = number(body["max"]), a >= 0, b >= 0, max(a, b) > 0 else { return nil }
378
+ return min(a, b)...max(a, b)
379
+ }
380
+ func zone() -> Int? {
381
+ guard let value = number(body["zone"]), value >= 1, value.rounded() == value else { return nil }
382
+ return Int(value)
383
+ }
384
+ switch type {
385
+ case "heartRate":
386
+ guard let range = range() else { return invalid("needs min and max") }
387
+ return .success((.heartRate(range), type))
388
+ case "heartRateZone":
389
+ guard let zone = zone() else { return invalid("needs a zone from 1") }
390
+ return .success((.heartRate(zone: zone), type))
391
+ case "power":
392
+ guard let range = range() else { return invalid("needs min and max") }
393
+ return .success((.power(range, unit: .watts), type))
394
+ case "powerZone":
395
+ guard let zone = zone() else { return invalid("needs a zone from 1") }
396
+ return .success((.power(zone: zone), type))
397
+ case "speed":
398
+ guard let range = range() else { return invalid("needs min and max") }
399
+ return .success((.speed(range, unit: .metersPerSecond, metric: .current), type))
400
+ case "cadence":
401
+ guard let range = range() else { return invalid("needs min and max") }
402
+ return .success((.cadence(range), type))
403
+ default:
404
+ return invalid("is not an alert WorkoutKit knows")
405
+ }
406
+ }
407
+
408
+ static func build(_ body: [String: Any]) -> Result<Built, CraftScheduledWorkoutsError> {
409
+ guard let idText = body["id"] as? String, let id = UUID(uuidString: idText) else {
410
+ return refuse("A scheduled workout needs an id (a UUID)")
411
+ }
412
+ guard let date = date(body) else {
413
+ return refuse("A scheduled workout needs a date ('YYYY-MM-DD') and, when given, an hour 0-23 and a minute 0-59")
414
+ }
415
+ guard let workout = body["workout"] as? [String: Any] else { return refuse("A scheduled workout needs a workout") }
416
+ let activityName = workout["activity"] as? String ?? ""
417
+ guard let activity = activities[activityName] else {
418
+ return refuse("WorkoutKit has no activity '\(activityName)'")
419
+ }
420
+ guard CustomWorkout.supportsActivity(activity) else {
421
+ return refuse("WorkoutKit cannot schedule a custom \(activityName) workout")
422
+ }
423
+ let locationName = workout["location"] as? String ?? "unknown"
424
+ guard var location = locations[locationName] else {
425
+ return refuse("A workout's location is indoor, outdoor or unknown, not '\(locationName)'")
426
+ }
427
+ // CustomWorkout has no swimming location of its own (and no pool
428
+ // length; the watch asks for that when the swim starts): a pool swim
429
+ // is indoor, open water outdoor.
430
+ if activity == .swimming, location == .unknown {
431
+ switch workout["swimmingLocation"] as? String {
432
+ case "openWater": location = .outdoor
433
+ case "pool", nil: location = .indoor
434
+ default: break
435
+ }
436
+ }
437
+ let context = "\(activityName) (\(locationName == "unknown" && location != .unknown ? (location == .indoor ? "indoor" : "outdoor") : locationName))"
438
+ var dropped: [String] = []
439
+
440
+ func step(_ value: Any?, at place: String) -> Result<WorkoutStep, CraftScheduledWorkoutsError> {
441
+ guard let body = value as? [String: Any] else {
442
+ return .failure(CraftScheduledWorkoutsError(message: "\(place) needs a goal", code: "INVALID_ARGUMENT"))
443
+ }
444
+ let parsedGoal: (goal: WorkoutGoal, name: String)
445
+ switch goal(body["goal"]) {
446
+ case .success(let value): parsedGoal = value
447
+ case .failure(let error): return .failure(CraftScheduledWorkoutsError(message: "\(place): \(error.message)", code: error.code))
448
+ }
449
+ guard CustomWorkout.supportsGoal(parsedGoal.goal, activity: activity, location: location) else {
450
+ return .failure(CraftScheduledWorkoutsError(message: "WorkoutKit refuses a \(parsedGoal.name) goal for \(context) (\(place))", code: "UNSUPPORTED_GOAL"))
451
+ }
452
+ var result = WorkoutStep(goal: parsedGoal.goal)
453
+ if let alertBody = body["alert"], !(alertBody is NSNull) {
454
+ switch alert(alertBody) {
455
+ case .success(let parsed):
456
+ if CustomWorkout.supportsAlert(parsed.alert, activity: activity, location: location) {
457
+ result.alert = parsed.alert
458
+ } else {
459
+ dropped.append("\(place): \(parsed.name) alert, which WorkoutKit refuses for \(context)")
460
+ }
461
+ case .failure(let error):
462
+ dropped.append("\(place): \(error.message)")
463
+ }
464
+ }
465
+ if #available(iOS 18.0, *), let name = (body["displayName"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
466
+ result.displayName = String(name.prefix(64))
467
+ }
468
+ return .success(result)
469
+ }
470
+
471
+ var warmup: WorkoutStep?
472
+ if let value = workout["warmup"], !(value is NSNull) {
473
+ switch step(value, at: "warmup") {
474
+ case .success(let parsed): warmup = parsed
475
+ case .failure(let error): return .failure(error)
476
+ }
477
+ }
478
+ var cooldown: WorkoutStep?
479
+ if let value = workout["cooldown"], !(value is NSNull) {
480
+ switch step(value, at: "cooldown") {
481
+ case .success(let parsed): cooldown = parsed
482
+ case .failure(let error): return .failure(error)
483
+ }
484
+ }
485
+ var blocks: [IntervalBlock] = []
486
+ for (blockIndex, value) in (workout["blocks"] as? [Any] ?? []).enumerated() {
487
+ let block = value as? [String: Any] ?? [:]
488
+ let iterations = number(block["iterations"] ?? 1).map { Int($0) } ?? 0
489
+ guard iterations >= 1 else { return refuse("block \(blockIndex + 1) needs iterations of 1 or more") }
490
+ var steps: [IntervalStep] = []
491
+ for (stepIndex, entry) in (block["steps"] as? [Any] ?? []).enumerated() {
492
+ let place = "block \(blockIndex + 1) step \(stepIndex + 1)"
493
+ let entry = entry as? [String: Any] ?? [:]
494
+ let purpose: IntervalStep.Purpose
495
+ switch entry["purpose"] as? String {
496
+ case "work": purpose = .work
497
+ case "recovery": purpose = .recovery
498
+ default: return refuse("\(place) needs a purpose: work or recovery")
499
+ }
500
+ switch step(entry["step"], at: place) {
501
+ case .success(let parsed): steps.append(IntervalStep(purpose, step: parsed))
502
+ case .failure(let error): return .failure(error)
503
+ }
504
+ }
505
+ guard !steps.isEmpty else { return refuse("block \(blockIndex + 1) has no steps") }
506
+ blocks.append(IntervalBlock(steps: steps, iterations: iterations))
507
+ }
508
+ guard warmup != nil || cooldown != nil || !blocks.isEmpty else {
509
+ return refuse("A scheduled workout needs at least one step")
510
+ }
511
+ let displayName = (workout["displayName"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
512
+ let custom = CustomWorkout(
513
+ activity: activity,
514
+ location: location,
515
+ displayName: displayName?.isEmpty == false ? displayName : nil,
516
+ warmup: warmup,
517
+ blocks: blocks,
518
+ cooldown: cooldown
519
+ )
520
+ return .success(Built(plan: WorkoutPlan(.custom(custom), id: id), date: date, droppedAlerts: dropped))
521
+ }
522
+ }
523
+
524
+ #endif
@@ -39,6 +39,7 @@ targets:
39
39
  SWIFT_VERSION: "5.0"
40
40
  ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
41
41
  {{CRAFT_RUNTIME_SETTINGS}}
42
+ {{FEATURE_SETTINGS}}
42
43
  {{NATIVE_DEPENDENCIES}}
43
44
  {{NATIVE_TARGETS}}
44
45
  # A shared scheme, so CI (Xcode Cloud, a fresh checkout's xcodebuild) can
package/dist/mobile.js CHANGED
@@ -981,6 +981,72 @@ var heartRate = {
981
981
  onDevice: (callback) => hrSubscribe(heartRateWatch.devices, callback),
982
982
  onState: (callback) => hrSubscribe(heartRateWatch.states, callback)
983
983
  };
984
+ var SCHEDULED_AUTHORIZATIONS = ["authorized", "denied", "notDetermined", "restricted", "unsupported"];
985
+ function nativeScheduledWorkouts() {
986
+ const craft = getCraftRoot();
987
+ if (!craft?.scheduledWorkouts || craft.capabilities?.scheduledWorkouts !== true)
988
+ return;
989
+ return craft.scheduledWorkouts;
990
+ }
991
+ function scheduledWorkoutsUnsupported() {
992
+ return Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" });
993
+ }
994
+ function scheduledAuthorization(value) {
995
+ return SCHEDULED_AUTHORIZATIONS.includes(value) ? value : "unsupported";
996
+ }
997
+ function normalizeScheduledWorkout(value) {
998
+ if (!value || typeof value !== "object")
999
+ return null;
1000
+ const row = value;
1001
+ if (typeof row.id !== "string" || !row.id || typeof row.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(row.date))
1002
+ return null;
1003
+ const out = { id: row.id.toLowerCase(), date: row.date, complete: row.complete === true };
1004
+ if (typeof row.hour === "number" && Number.isFinite(row.hour))
1005
+ out.hour = row.hour;
1006
+ if (typeof row.minute === "number" && Number.isFinite(row.minute))
1007
+ out.minute = row.minute;
1008
+ return out;
1009
+ }
1010
+ async function scheduledCall(method, normalize, ...args) {
1011
+ const native = nativeScheduledWorkouts();
1012
+ if (typeof native?.[method] !== "function")
1013
+ throw scheduledWorkoutsUnsupported();
1014
+ return normalize(await native[method](...args));
1015
+ }
1016
+ var scheduledWorkouts = {
1017
+ isAvailable() {
1018
+ return typeof nativeScheduledWorkouts()?.isSupported === "function";
1019
+ },
1020
+ supports(method) {
1021
+ return typeof nativeScheduledWorkouts()?.[method] === "function";
1022
+ },
1023
+ async isSupported() {
1024
+ const native = nativeScheduledWorkouts();
1025
+ if (typeof native?.isSupported !== "function")
1026
+ return false;
1027
+ try {
1028
+ return await native.isSupported() === true;
1029
+ } catch {
1030
+ return false;
1031
+ }
1032
+ },
1033
+ authorization: () => scheduledCall("authorization", scheduledAuthorization),
1034
+ requestAuthorization: () => scheduledCall("requestAuthorization", scheduledAuthorization),
1035
+ list: () => scheduledCall("list", (value) => Array.isArray(value) ? value.map(normalizeScheduledWorkout).filter((row) => row !== null) : []),
1036
+ schedule: (plan) => {
1037
+ if (!plan || typeof plan !== "object" || typeof plan.id !== "string")
1038
+ return Promise.reject(Object.assign(new Error("schedule needs a plan with an id"), { code: "INVALID_ARGUMENT" }));
1039
+ return scheduledCall("schedule", (value) => {
1040
+ const row = value && typeof value === "object" ? value : {};
1041
+ const out = { id: typeof row.id === "string" ? row.id.toLowerCase() : plan.id.toLowerCase() };
1042
+ if (Array.isArray(row.droppedAlerts) && row.droppedAlerts.length)
1043
+ out.droppedAlerts = row.droppedAlerts.filter((item) => typeof item === "string");
1044
+ return out;
1045
+ }, plan);
1046
+ },
1047
+ remove: (id) => scheduledCall("remove", (value) => value === true, id),
1048
+ removeAll: () => scheduledCall("removeAll", (value) => typeof value === "number" && Number.isFinite(value) ? value : 0)
1049
+ };
984
1050
  var deepLinks = {
985
1051
  async getInitialURL() {
986
1052
  return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.());
@@ -1479,10 +1545,13 @@ export {
1479
1545
  snapshots,
1480
1546
  share,
1481
1547
  secureStorage,
1548
+ scheduledWorkoutsUnsupported,
1549
+ scheduledWorkouts,
1482
1550
  refresh,
1483
1551
  pushNotifications,
1484
1552
  permissions,
1485
1553
  notifications,
1554
+ normalizeScheduledWorkout,
1486
1555
  normalizeNowPlaying,
1487
1556
  normalizeHeartRateState,
1488
1557
  normalizeHeartRateSamples,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.162",
3
+ "version": "0.0.163",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",