@craft-native/ios 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.
- package/README.md +47 -0
- package/dist/cli.js +11 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.js +12 -1
- package/package.json +1 -1
- package/templates/CraftApp.swift +37 -0
- package/templates/CraftNativeActions.swift +14 -0
- package/templates/CraftNativeScreen.swift +4 -0
- package/templates/CraftScheduledWorkouts.swift +524 -0
- package/templates/project.yml.template +1 -0
package/README.md
CHANGED
|
@@ -144,6 +144,11 @@ the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
|
|
|
144
144
|
`bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
|
|
145
145
|
simulator UI tests.
|
|
146
146
|
|
|
147
|
+
The iOS host uses the shared layout ABI when it is available and retains a
|
|
148
|
+
local fallback for older runtimes. Both paths implement wrapped-line and grid
|
|
149
|
+
`alignContent`, including `space-evenly`, so a missing optional shared symbol
|
|
150
|
+
does not change placement semantics.
|
|
151
|
+
|
|
147
152
|
For multiple native screens, configure named `.stx` routes and an initial screen in `native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
|
|
148
153
|
|
|
149
154
|
The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
|
|
@@ -186,6 +191,7 @@ Edit `craft.config.json` in your iOS project:
|
|
|
186
191
|
"enableAppleMusic": false,
|
|
187
192
|
"enableHeartRate": false,
|
|
188
193
|
"enableBackgroundAudio": false,
|
|
194
|
+
"enableScheduledWorkouts": false,
|
|
189
195
|
"enableScreenCapture": true,
|
|
190
196
|
"iosVersion": "15.0",
|
|
191
197
|
"teamId": ""
|
|
@@ -609,6 +615,47 @@ const { samples } = await window.craft.heartRate.stopRecording(); // [{ timestam
|
|
|
609
615
|
// <bundle id> CraftFakeHeartRateMonitor -bool YES` stands in a strap ("Simulated
|
|
610
616
|
// HRM") whose readings are encoded and decoded as 0x2A37 payloads. Simulator builds only.
|
|
611
617
|
|
|
618
|
+
// Apple Watch workouts (enableScheduledWorkouts, iOS 17+): WorkoutKit's
|
|
619
|
+
// WorkoutScheduler puts a structured workout in the paired watch's Workout
|
|
620
|
+
// app on its day. Only an app with the flag compiles in and links WorkoutKit
|
|
621
|
+
// (the CRAFT_SCHEDULED_WORKOUTS build condition); below iOS 17 it is weak-linked
|
|
622
|
+
// and isSupported() answers false. A plan is plain JSON in SI units.
|
|
623
|
+
if (await window.craft.scheduledWorkouts.isSupported()) {
|
|
624
|
+
await window.craft.scheduledWorkouts.requestAuthorization(); // iOS's prompt once; waits on the person
|
|
625
|
+
await window.craft.scheduledWorkouts.schedule({
|
|
626
|
+
id: '6f1c2b8e-5a7d-4c3e-9b1a-2d4e6f8a0b1c', // a UUID: WorkoutPlan's id
|
|
627
|
+
date: '2026-10-20', hour: 6, minute: 30, // local; 07:00 without a time
|
|
628
|
+
workout: {
|
|
629
|
+
activity: 'running', // an HKWorkoutActivityType case name
|
|
630
|
+
location: 'outdoor', // indoor | outdoor | unknown
|
|
631
|
+
displayName: '4 x 1 km',
|
|
632
|
+
warmup: { goal: { type: 'time', seconds: 600 } },
|
|
633
|
+
blocks: [{ iterations: 4, steps: [
|
|
634
|
+
{ purpose: 'work', step: { goal: { type: 'distance', meters: 1000 },
|
|
635
|
+
alert: { type: 'speed', min: 4.1, max: 4.4 }, displayName: '1 km hard' } }, // step names: iOS 18
|
|
636
|
+
{ purpose: 'recovery', step: { goal: { type: 'time', seconds: 120 } } },
|
|
637
|
+
] }],
|
|
638
|
+
cooldown: { goal: { type: 'open' } },
|
|
639
|
+
},
|
|
640
|
+
}); // { id, droppedAlerts? }
|
|
641
|
+
await window.craft.scheduledWorkouts.list(); // [{ id, date, hour, minute, complete }], ids lowercase
|
|
642
|
+
await window.craft.scheduledWorkouts.remove(id); // every day it is on; true when there was one
|
|
643
|
+
await window.craft.scheduledWorkouts.removeAll(); // how many there were
|
|
644
|
+
}
|
|
645
|
+
// Goals: open, time (seconds), distance (meters), energy (kilocalories).
|
|
646
|
+
// Alerts: heartRate / power / speed (m/s) / cadence { min, max }, heartRateZone /
|
|
647
|
+
// powerZone { zone }. An activity or goal WorkoutKit refuses rejects, naming it
|
|
648
|
+
// (UNSUPPORTED_GOAL, INVALID_ARGUMENT); a refused alert is dropped and named in
|
|
649
|
+
// droppedAlerts. LIMIT_REACHED past WorkoutScheduler.maxAllowedScheduledWorkoutCount;
|
|
650
|
+
// NOT_SCHEDULED when nothing kept the plan (no paired watch); NOT_AUTHORIZED
|
|
651
|
+
// before access. Swimming: CustomWorkout has no swimming location or pool
|
|
652
|
+
// length, so with location 'unknown' a 'pool' swim is indoor, 'openWater' outdoor.
|
|
653
|
+
// Native screens call the same service as the ScheduledWorkouts module
|
|
654
|
+
// (capability `scheduledWorkouts`); or use `scheduledWorkouts` from 'craft-native/mobile'.
|
|
655
|
+
// A simulator never has a watch: `simctl spawn <device> defaults write <bundle id>
|
|
656
|
+
// CraftFakeWorkoutScheduler -bool YES` keeps plans in the app instead (WorkoutKit
|
|
657
|
+
// still builds and checks them). Simulator builds only.
|
|
658
|
+
|
|
612
659
|
// Files on a native screen (capability `files`, always there): a photo from
|
|
613
660
|
// the library (PHPickerViewController, no permission asked) or the camera
|
|
614
661
|
// (`source: 'camera'`, capability `camera`, needs enableCamera), saved as an
|
package/dist/cli.js
CHANGED
|
@@ -220,6 +220,7 @@ var DEFAULT_CONFIG = {
|
|
|
220
220
|
enableMLKit: false,
|
|
221
221
|
enableAppleMusic: false,
|
|
222
222
|
enableHeartRate: false,
|
|
223
|
+
enableScheduledWorkouts: false,
|
|
223
224
|
iosVersion: "16.0",
|
|
224
225
|
watchosVersion: "9.0",
|
|
225
226
|
teamId: "",
|
|
@@ -454,6 +455,14 @@ function renderRuntimeSettings() {
|
|
|
454
455
|
].join(`
|
|
455
456
|
`);
|
|
456
457
|
}
|
|
458
|
+
function renderFeatureSettings(config) {
|
|
459
|
+
const conditions = [];
|
|
460
|
+
if (config.enableScheduledWorkouts)
|
|
461
|
+
conditions.push("CRAFT_SCHEDULED_WORKOUTS");
|
|
462
|
+
if (!conditions.length)
|
|
463
|
+
return "";
|
|
464
|
+
return ` SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) ${conditions.join(" ")}"`;
|
|
465
|
+
}
|
|
457
466
|
function renderPrivacyManifest(config) {
|
|
458
467
|
const privacy = config.privacy ?? {};
|
|
459
468
|
const collected = privacy.collectedDataTypes ?? [];
|
|
@@ -811,6 +820,7 @@ function writeShell(output, config) {
|
|
|
811
820
|
cpSync(join2(TEMPLATES_DIR, "CraftHealth.swift"), join2(output, "Sources", "CraftHealth.swift"));
|
|
812
821
|
cpSync(join2(TEMPLATES_DIR, "CraftMusic.swift"), join2(output, "Sources", "CraftMusic.swift"));
|
|
813
822
|
cpSync(join2(TEMPLATES_DIR, "CraftHeartRate.swift"), join2(output, "Sources", "CraftHeartRate.swift"));
|
|
823
|
+
cpSync(join2(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join2(output, "Sources", "CraftScheduledWorkouts.swift"));
|
|
814
824
|
cpSync(join2(TEMPLATES_DIR, "CraftNativeMutation.swift"), join2(output, "Sources", "CraftNativeMutation.swift"));
|
|
815
825
|
cpSync(join2(TEMPLATES_DIR, "CraftNativeUI.swift"), join2(output, "Sources", "CraftNativeUI.swift"));
|
|
816
826
|
cpSync(join2(TEMPLATES_DIR, "CraftNativeServices.swift"), join2(output, "Sources", "CraftNativeServices.swift"));
|
|
@@ -932,7 +942,7 @@ async function init(options) {
|
|
|
932
942
|
} else {
|
|
933
943
|
rmSync(join2(output, "Runtime"), { recursive: true, force: true });
|
|
934
944
|
}
|
|
935
|
-
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:
|
|
945
|
+
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:
|
|
936
946
|
${nativeDependencies.join(`
|
|
937
947
|
`)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
|
|
938
948
|
`));
|
package/dist/index.d.ts
CHANGED
|
@@ -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
|
package/dist/index.js
CHANGED
|
@@ -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,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|