@craft-native/ios 0.0.162 → 0.0.164
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 +53 -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 +68 -0
- package/templates/CraftHealth.swift +48 -2
- package/templates/CraftHybrid.swift +49 -0
- package/templates/CraftNativeActions.swift +125 -1
- package/templates/CraftNativeFiles.swift +107 -0
- package/templates/CraftNativeScreen.swift +38 -3
- package/templates/CraftScheduledWorkouts.swift +524 -0
- package/templates/project.yml.template +1 -0
package/README.md
CHANGED
|
@@ -144,6 +144,13 @@ 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
|
+
Reverse flex directions also retain wrapped line order on the cross axis while
|
|
152
|
+
reversing each line's main-axis children, matching Android's native host.
|
|
153
|
+
|
|
147
154
|
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
155
|
|
|
149
156
|
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 +193,7 @@ Edit `craft.config.json` in your iOS project:
|
|
|
186
193
|
"enableAppleMusic": false,
|
|
187
194
|
"enableHeartRate": false,
|
|
188
195
|
"enableBackgroundAudio": false,
|
|
196
|
+
"enableScheduledWorkouts": false,
|
|
189
197
|
"enableScreenCapture": true,
|
|
190
198
|
"iosVersion": "15.0",
|
|
191
199
|
"teamId": ""
|
|
@@ -399,6 +407,10 @@ const workouts = await health.getWorkouts({ startDate: Date.now() - 30 * 8640000
|
|
|
399
407
|
const hrv = await health.getDailyStatistics('heartRateVariability', { startDate: Date.now() - 30 * 86400000 })
|
|
400
408
|
// What a food log wrote (MyFitnessPal, Lose It!...): kcal, then grams of protein, carbohydrate and fat, summed per day.
|
|
401
409
|
const eaten = await health.getDailyStatistics('dietaryEnergy', { startDate: Date.now() - 7 * 86400000 })
|
|
410
|
+
// Food logs can write a quantity too; request write access first. The result
|
|
411
|
+
// contains HealthKit's sample id, and the date range defaults to this instant.
|
|
412
|
+
await health.requestAuthorization(['dietaryEnergy', 'dietaryProtein'], { write: true })
|
|
413
|
+
const sample = await health.saveData('dietaryProtein', 28, { startDate: mealStart, endDate: mealEnd })
|
|
402
414
|
// Every heart-rate reading in a range (bpm, start/end, source and device), oldest first.
|
|
403
415
|
const readings = await health.getHeartRateSamples({ startDate: Date.now() - 86400000 })
|
|
404
416
|
// The night as Apple Health recorded it: in bed, awake, and each asleep stage.
|
|
@@ -609,6 +621,47 @@ const { samples } = await window.craft.heartRate.stopRecording(); // [{ timestam
|
|
|
609
621
|
// <bundle id> CraftFakeHeartRateMonitor -bool YES` stands in a strap ("Simulated
|
|
610
622
|
// HRM") whose readings are encoded and decoded as 0x2A37 payloads. Simulator builds only.
|
|
611
623
|
|
|
624
|
+
// Apple Watch workouts (enableScheduledWorkouts, iOS 17+): WorkoutKit's
|
|
625
|
+
// WorkoutScheduler puts a structured workout in the paired watch's Workout
|
|
626
|
+
// app on its day. Only an app with the flag compiles in and links WorkoutKit
|
|
627
|
+
// (the CRAFT_SCHEDULED_WORKOUTS build condition); below iOS 17 it is weak-linked
|
|
628
|
+
// and isSupported() answers false. A plan is plain JSON in SI units.
|
|
629
|
+
if (await window.craft.scheduledWorkouts.isSupported()) {
|
|
630
|
+
await window.craft.scheduledWorkouts.requestAuthorization(); // iOS's prompt once; waits on the person
|
|
631
|
+
await window.craft.scheduledWorkouts.schedule({
|
|
632
|
+
id: '6f1c2b8e-5a7d-4c3e-9b1a-2d4e6f8a0b1c', // a UUID: WorkoutPlan's id
|
|
633
|
+
date: '2026-10-20', hour: 6, minute: 30, // local; 07:00 without a time
|
|
634
|
+
workout: {
|
|
635
|
+
activity: 'running', // an HKWorkoutActivityType case name
|
|
636
|
+
location: 'outdoor', // indoor | outdoor | unknown
|
|
637
|
+
displayName: '4 x 1 km',
|
|
638
|
+
warmup: { goal: { type: 'time', seconds: 600 } },
|
|
639
|
+
blocks: [{ iterations: 4, steps: [
|
|
640
|
+
{ purpose: 'work', step: { goal: { type: 'distance', meters: 1000 },
|
|
641
|
+
alert: { type: 'speed', min: 4.1, max: 4.4 }, displayName: '1 km hard' } }, // step names: iOS 18
|
|
642
|
+
{ purpose: 'recovery', step: { goal: { type: 'time', seconds: 120 } } },
|
|
643
|
+
] }],
|
|
644
|
+
cooldown: { goal: { type: 'open' } },
|
|
645
|
+
},
|
|
646
|
+
}); // { id, droppedAlerts? }
|
|
647
|
+
await window.craft.scheduledWorkouts.list(); // [{ id, date, hour, minute, complete }], ids lowercase
|
|
648
|
+
await window.craft.scheduledWorkouts.remove(id); // every day it is on; true when there was one
|
|
649
|
+
await window.craft.scheduledWorkouts.removeAll(); // how many there were
|
|
650
|
+
}
|
|
651
|
+
// Goals: open, time (seconds), distance (meters), energy (kilocalories).
|
|
652
|
+
// Alerts: heartRate / power / speed (m/s) / cadence { min, max }, heartRateZone /
|
|
653
|
+
// powerZone { zone }. An activity or goal WorkoutKit refuses rejects, naming it
|
|
654
|
+
// (UNSUPPORTED_GOAL, INVALID_ARGUMENT); a refused alert is dropped and named in
|
|
655
|
+
// droppedAlerts. LIMIT_REACHED past WorkoutScheduler.maxAllowedScheduledWorkoutCount;
|
|
656
|
+
// NOT_SCHEDULED when nothing kept the plan (no paired watch); NOT_AUTHORIZED
|
|
657
|
+
// before access. Swimming: CustomWorkout has no swimming location or pool
|
|
658
|
+
// length, so with location 'unknown' a 'pool' swim is indoor, 'openWater' outdoor.
|
|
659
|
+
// Native screens call the same service as the ScheduledWorkouts module
|
|
660
|
+
// (capability `scheduledWorkouts`); or use `scheduledWorkouts` from 'craft-native/mobile'.
|
|
661
|
+
// A simulator never has a watch: `simctl spawn <device> defaults write <bundle id>
|
|
662
|
+
// CraftFakeWorkoutScheduler -bool YES` keeps plans in the app instead (WorkoutKit
|
|
663
|
+
// still builds and checks them). Simulator builds only.
|
|
664
|
+
|
|
612
665
|
// Files on a native screen (capability `files`, always there): a photo from
|
|
613
666
|
// the library (PHPickerViewController, no permission asked) or the camera
|
|
614
667
|
// (`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
|
@@ -738,6 +738,18 @@ final class CraftChrome: ObservableObject {
|
|
|
738
738
|
emit("craftTabSelect", ["id": tab.id])
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/// A completed horizontal swipe selects the adjacent tab. The shell owns
|
|
742
|
+
/// this path so it works over both native screens and the shared WebView.
|
|
743
|
+
/// The gesture recognizer yields before calling this for a screen-owned
|
|
744
|
+
/// horizontal scroll or custom pager.
|
|
745
|
+
func swipe(to id: String) {
|
|
746
|
+
guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
|
|
747
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
748
|
+
UISelectionFeedbackGenerator().selectionChanged()
|
|
749
|
+
guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
|
|
750
|
+
emit("craftTabSelect", ["id": id])
|
|
751
|
+
}
|
|
752
|
+
|
|
741
753
|
/// The animation, or none with Reduce Motion on: the selection jumps to
|
|
742
754
|
/// its tab rather than springing there, as UIKit's own controls do.
|
|
743
755
|
static func motion(_ animation: Animation) -> Animation? {
|
|
@@ -1212,6 +1224,9 @@ struct CraftConfig: Codable {
|
|
|
1212
1224
|
/// A Bluetooth heart-rate strap (or a watch broadcasting its heart rate)
|
|
1213
1225
|
/// through `craft.heartRate` (CraftHeartRate.swift).
|
|
1214
1226
|
var enableHeartRate: Bool = false
|
|
1227
|
+
/// Structured workouts scheduled onto the paired Apple Watch through
|
|
1228
|
+
/// `craft.scheduledWorkouts` (CraftScheduledWorkouts.swift, WorkoutKit).
|
|
1229
|
+
var enableScheduledWorkouts: Bool = false
|
|
1215
1230
|
var devServerURL: String? = nil
|
|
1216
1231
|
var trustedOrigins: [String] = []
|
|
1217
1232
|
/// The longest the launch splash stays up when the page neither says it is
|
|
@@ -2335,6 +2350,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2335
2350
|
}
|
|
2336
2351
|
}
|
|
2337
2352
|
}
|
|
2353
|
+
// MARK: - Scheduled workouts (CraftScheduledWorkouts.swift)
|
|
2354
|
+
// Gated on enableScheduledWorkouts. One action for every method,
|
|
2355
|
+
// as a native screen's ScheduledWorkouts module calls them;
|
|
2356
|
+
// asking to schedule has its own, which waits on the person.
|
|
2357
|
+
case "scheduledWorkouts", "scheduledWorkoutsRequestAuthorization":
|
|
2358
|
+
if !config.enableScheduledWorkouts {
|
|
2359
|
+
rejectCallback(callbackId, error: "Scheduled workouts are disabled", code: "CAPABILITY_DISABLED")
|
|
2360
|
+
} else {
|
|
2361
|
+
let method = action == "scheduledWorkouts" ? body["method"] as? String ?? "" : "requestAuthorization"
|
|
2362
|
+
CraftScheduledWorkouts.shared.perform(method: method, args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
|
|
2363
|
+
switch result {
|
|
2364
|
+
case .success(let value): self?.resolveCallback(callbackId, result: value)
|
|
2365
|
+
case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2338
2369
|
// MARK: - Orientation Lock
|
|
2339
2370
|
case "lockOrientation":
|
|
2340
2371
|
if config.enableOrientationLock {
|
|
@@ -2495,6 +2526,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2495
2526
|
} else {
|
|
2496
2527
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2497
2528
|
}
|
|
2529
|
+
case "saveHealthData":
|
|
2530
|
+
if config.enableHealthKit {
|
|
2531
|
+
guard let dataType = body["type"] as? String,
|
|
2532
|
+
let value = (body["value"] as? NSNumber)?.doubleValue else {
|
|
2533
|
+
rejectCallback(callbackId, error: "saveHealthData was called without a type and numeric value", code: "INVALID_ARGUMENT")
|
|
2534
|
+
break
|
|
2535
|
+
}
|
|
2536
|
+
CraftHealth.shared.saveData(
|
|
2537
|
+
type: dataType,
|
|
2538
|
+
value: value,
|
|
2539
|
+
options: body
|
|
2540
|
+
) { [weak self] in self?.answer(callbackId, $0) }
|
|
2541
|
+
} else {
|
|
2542
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2543
|
+
}
|
|
2498
2544
|
case "saveHealthWorkout":
|
|
2499
2545
|
if config.enableHealthKit {
|
|
2500
2546
|
saveHealthWorkout(body: body, callbackId: callbackId)
|
|
@@ -3337,6 +3383,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3337
3383
|
brightness: true,
|
|
3338
3384
|
music: \(config.enableAppleMusic),
|
|
3339
3385
|
heartRate: \(config.enableHeartRate),
|
|
3386
|
+
scheduledWorkouts: \(config.enableScheduledWorkouts),
|
|
3340
3387
|
speech: true,
|
|
3341
3388
|
network: true,
|
|
3342
3389
|
deviceInfo: true,
|
|
@@ -4877,6 +4924,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4877
4924
|
requestHealthAuthorization: true,
|
|
4878
4925
|
musicRequestAuthorization: true,
|
|
4879
4926
|
heartRateRequestAuthorization: true,
|
|
4927
|
+
scheduledWorkoutsRequestAuthorization: true,
|
|
4880
4928
|
requestPermission: true,
|
|
4881
4929
|
signInWithApple: true,
|
|
4882
4930
|
dialogAlert: true,
|
|
@@ -5056,6 +5104,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5056
5104
|
options = options || {};
|
|
5057
5105
|
return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
5058
5106
|
},
|
|
5107
|
+
saveData: function(type, value, options) {
|
|
5108
|
+
options = options || {};
|
|
5109
|
+
return craft._invoke('saveHealthData', {type: type, value: value, startDate: options.startDate, endDate: options.endDate});
|
|
5110
|
+
},
|
|
5059
5111
|
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
|
|
5060
5112
|
getWorkouts: function(options) {
|
|
5061
5113
|
options = options || {};
|
|
@@ -5123,6 +5175,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5123
5175
|
readRecording: function(options) { return heartRate('readRecording', {since: options && typeof options.since === 'number' ? options.since : null}); }
|
|
5124
5176
|
};
|
|
5125
5177
|
})();
|
|
5178
|
+
// Structured workouts on the paired Apple Watch (WorkoutKit,
|
|
5179
|
+
// iOS 17+): each appears in its Workout app on its day.
|
|
5180
|
+
// schedule(plan) answers {id} or rejects naming what WorkoutKit
|
|
5181
|
+
// refused; list() answers [{id, date, hour, minute, complete}].
|
|
5182
|
+
(function() {
|
|
5183
|
+
function scheduled(method, options) { return craft._invoke('scheduledWorkouts', {method: method, options: options || {}}); }
|
|
5184
|
+
craft.scheduledWorkouts = {
|
|
5185
|
+
isSupported: function() { return scheduled('isSupported'); },
|
|
5186
|
+
authorization: function() { return scheduled('authorization'); },
|
|
5187
|
+
requestAuthorization: function() { return craft._invoke('scheduledWorkoutsRequestAuthorization'); },
|
|
5188
|
+
list: function() { return scheduled('list'); },
|
|
5189
|
+
schedule: function(plan) { return scheduled('schedule', plan); },
|
|
5190
|
+
remove: function(id) { return scheduled('remove', {id: typeof id === 'string' ? id : id && id.id}); },
|
|
5191
|
+
removeAll: function() { return scheduled('removeAll'); }
|
|
5192
|
+
};
|
|
5193
|
+
})();
|
|
5126
5194
|
craft.liveActivity = {
|
|
5127
5195
|
start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
|
|
5128
5196
|
update: function(idOrState, state) {
|
|
@@ -32,6 +32,13 @@ final class CraftHealth {
|
|
|
32
32
|
case "getData":
|
|
33
33
|
guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
|
|
34
34
|
getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
|
|
35
|
+
case "saveData":
|
|
36
|
+
guard let type = args.first as? String,
|
|
37
|
+
args.count > 1,
|
|
38
|
+
let value = (args[1] as? NSNumber)?.doubleValue else {
|
|
39
|
+
return completion(.failure(CraftHealthError(message: "saveData needs a type and numeric value", code: "INVALID_ARGUMENT")))
|
|
40
|
+
}
|
|
41
|
+
saveData(type: type, value: value, options: options(2), completion: completion)
|
|
35
42
|
case "saveWorkout":
|
|
36
43
|
saveWorkout(body: options(0), completion: completion)
|
|
37
44
|
case "getWorkouts":
|
|
@@ -120,12 +127,15 @@ final class CraftHealth {
|
|
|
120
127
|
}
|
|
121
128
|
case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
|
|
122
129
|
// What a food log (MyFitnessPal, Lose It!, Cronometer...)
|
|
123
|
-
// writes to Apple Health
|
|
130
|
+
// writes to Apple Health. Request the write grant too unless
|
|
131
|
+
// the caller explicitly asked for a read-only authorization.
|
|
124
132
|
if let dietaryType = Self.healthQuantity(type)?.type {
|
|
125
133
|
readTypes.insert(dietaryType)
|
|
134
|
+
if !readOnly { shareTypes.insert(dietaryType) }
|
|
126
135
|
}
|
|
127
136
|
default:
|
|
128
|
-
|
|
137
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
138
|
+
return
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -179,6 +189,42 @@ final class CraftHealth {
|
|
|
179
189
|
healthStore.execute(query)
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
/// Save one quantity sample, primarily a food-log entry. Dates are epoch
|
|
193
|
+
/// milliseconds; an omitted range records an instantaneous sample now.
|
|
194
|
+
func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
|
|
195
|
+
guard let healthStore = healthStore else {
|
|
196
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
guard value.isFinite, value >= 0 else {
|
|
200
|
+
completion(.failure(CraftHealthError(message: "saveData needs a finite, non-negative value", code: "INVALID_ARGUMENT")))
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
204
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
let startValue = options["startDate"] as? Double ?? Date().timeIntervalSince1970 * 1000
|
|
208
|
+
let endValue = options["endDate"] as? Double ?? startValue
|
|
209
|
+
guard startValue.isFinite, endValue.isFinite, endValue >= startValue else {
|
|
210
|
+
completion(.failure(CraftHealthError(message: "saveData needs a valid date range", code: "INVALID_ARGUMENT")))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
let sample = HKQuantitySample(
|
|
214
|
+
type: qType,
|
|
215
|
+
quantity: HKQuantity(unit: spec.unit, doubleValue: value),
|
|
216
|
+
start: Date(timeIntervalSince1970: startValue / 1000),
|
|
217
|
+
end: Date(timeIntervalSince1970: endValue / 1000)
|
|
218
|
+
)
|
|
219
|
+
healthStore.save(sample) { success, error in
|
|
220
|
+
if success {
|
|
221
|
+
completion(.success(["id": sample.uuid.uuidString]))
|
|
222
|
+
} else {
|
|
223
|
+
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Health data could not be saved")))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
182
228
|
func saveWorkout(body: [String: Any], completion: @escaping Completion) {
|
|
183
229
|
guard let healthStore = healthStore else {
|
|
184
230
|
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
@@ -1004,6 +1004,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1004
1004
|
private var openingWeb = false
|
|
1005
1005
|
private var firstFrameLogged = false
|
|
1006
1006
|
private var tabBarHeight: CGFloat = 0
|
|
1007
|
+
private var tabSwipeRecognizers: [UISwipeGestureRecognizer] = []
|
|
1007
1008
|
|
|
1008
1009
|
init(config: CraftConfig) {
|
|
1009
1010
|
self.config = config
|
|
@@ -1033,6 +1034,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1033
1034
|
let tabs = hybrid.tabIds
|
|
1034
1035
|
let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
|
|
1035
1036
|
show(tab: start, rootPath: hybrid.startPath)
|
|
1037
|
+
installTabSwipeGestures()
|
|
1036
1038
|
if hybrid.hasWeb && hybrid.startsNative {
|
|
1037
1039
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
|
|
1038
1040
|
}
|
|
@@ -1043,6 +1045,42 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1043
1045
|
|
|
1044
1046
|
private var webAttached = false
|
|
1045
1047
|
|
|
1048
|
+
private func installTabSwipeGestures() {
|
|
1049
|
+
guard tabSwipeRecognizers.isEmpty else { return }
|
|
1050
|
+
for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
|
|
1051
|
+
let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
|
|
1052
|
+
recognizer.direction = direction
|
|
1053
|
+
recognizer.delegate = self
|
|
1054
|
+
recognizer.cancelsTouchesInView = false
|
|
1055
|
+
recognizer.delaysTouchesBegan = false
|
|
1056
|
+
view.addGestureRecognizer(recognizer)
|
|
1057
|
+
tabSwipeRecognizers.append(recognizer)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@objc private func tabSwipeRecognized(_ recognizer: UISwipeGestureRecognizer) {
|
|
1062
|
+
let tabs = hybrid.tabIds
|
|
1063
|
+
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
1064
|
+
let next = recognizer.direction == .left ? index + 1 : index - 1
|
|
1065
|
+
guard tabs.indices.contains(next) else { return }
|
|
1066
|
+
CraftChrome.shared.swipe(to: tabs[next])
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
|
|
1070
|
+
let local = root.convert(point, from: view)
|
|
1071
|
+
guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
|
|
1072
|
+
var candidate: UIView? = hit
|
|
1073
|
+
while let current = candidate {
|
|
1074
|
+
if let scroll = current as? UIScrollView,
|
|
1075
|
+
scroll.isScrollEnabled,
|
|
1076
|
+
scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
|
|
1077
|
+
return true
|
|
1078
|
+
}
|
|
1079
|
+
candidate = current.superview
|
|
1080
|
+
}
|
|
1081
|
+
return false
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1046
1084
|
/// The web view joins the screen, behind the native screens, and loads.
|
|
1047
1085
|
private func attachWeb() {
|
|
1048
1086
|
guard let webHost, !webAttached else { return }
|
|
@@ -1481,8 +1519,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1481
1519
|
}
|
|
1482
1520
|
|
|
1483
1521
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1522
|
+
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1523
|
+
guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
|
|
1524
|
+
if let native = top as? CraftNativeScreenController,
|
|
1525
|
+
!native.tabSwipeEnabled || !native.contentSwipeBack { return false }
|
|
1526
|
+
return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
|
|
1527
|
+
}
|
|
1484
1528
|
guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
|
|
1485
1529
|
nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
|
|
1530
|
+
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1531
|
+
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1532
|
+
!native.contentSwipeBack {
|
|
1533
|
+
return false
|
|
1534
|
+
}
|
|
1486
1535
|
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1487
1536
|
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1488
1537
|
nativeScreenContainsHorizontalScroll(native) {
|