@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.162",
3
+ "version": "0.0.164",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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: read only, never written back.
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
- break
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) {