craft-native 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.
@@ -937,6 +937,142 @@ export declare const heartRate: {
937
937
  /** Bluetooth, scanning or the connection changed. */
938
938
  onState: (callback: (state: HeartRateState) => void) => (() => void);
939
939
  };
940
+ /**
941
+ * Structured workouts scheduled onto the paired Apple Watch (iOS 17+, an app
942
+ * built with `enableScheduledWorkouts`): each appears in the watch's Workout
943
+ * app on its day, with its steps, goals and alerts. Plain JSON in SI units.
944
+ * In a browser, on Android and in a build without the capability
945
+ * `isSupported()` answers false and every other call rejects with
946
+ * `unsupported` (code `UNSUPPORTED`).
947
+ */
948
+ export type ScheduledWorkoutsAuthorization = 'authorized' | 'denied' | 'notDetermined' | 'restricted' | 'unsupported';
949
+ export type AppleWorkoutGoal = {
950
+ type: 'open';
951
+ } | {
952
+ type: 'time';
953
+ seconds: number;
954
+ } | {
955
+ type: 'distance';
956
+ meters: number;
957
+ } | {
958
+ type: 'energy';
959
+ kilocalories: number;
960
+ };
961
+ export type AppleWorkoutAlert =
962
+ /** Beats per minute. */
963
+ {
964
+ type: 'heartRate';
965
+ min: number;
966
+ max: number;
967
+ } | {
968
+ type: 'heartRateZone';
969
+ zone: number;
970
+ }
971
+ /** Watts. */
972
+ | {
973
+ type: 'power';
974
+ min: number;
975
+ max: number;
976
+ } | {
977
+ type: 'powerZone';
978
+ zone: number;
979
+ }
980
+ /** Metres per second (the current speed); a pace range is sent as speed. */
981
+ | {
982
+ type: 'speed';
983
+ min: number;
984
+ max: number;
985
+ }
986
+ /** Steps or revolutions per minute. */
987
+ | {
988
+ type: 'cadence';
989
+ min: number;
990
+ max: number;
991
+ };
992
+ export interface AppleWorkoutStep {
993
+ goal: AppleWorkoutGoal;
994
+ /** Dropped (the step kept) when WorkoutKit refuses it for the activity. */
995
+ alert?: AppleWorkoutAlert;
996
+ /** Shown on the watch from iOS 18; ignored on 17. At most 64 characters. */
997
+ displayName?: string;
998
+ }
999
+ export interface AppleIntervalBlock {
1000
+ /** 1 or more. */
1001
+ iterations: number;
1002
+ steps: Array<{
1003
+ purpose: 'work' | 'recovery';
1004
+ step: AppleWorkoutStep;
1005
+ }>;
1006
+ }
1007
+ export interface AppleCustomWorkout {
1008
+ /** An HKWorkoutActivityType case name: 'running', 'cycling', 'swimming', 'traditionalStrengthTraining'... */
1009
+ activity: string;
1010
+ location: 'indoor' | 'outdoor' | 'unknown';
1011
+ /** Swimming only: a pool swim is scheduled indoor, open water outdoor, when `location` is 'unknown'. */
1012
+ swimmingLocation?: 'pool' | 'openWater';
1013
+ /** Accepted for the record; WorkoutKit takes no pool length (the watch asks when the swim starts). */
1014
+ poolLengthMeters?: number;
1015
+ displayName: string;
1016
+ warmup?: AppleWorkoutStep;
1017
+ cooldown?: AppleWorkoutStep;
1018
+ blocks: AppleIntervalBlock[];
1019
+ }
1020
+ export interface AppleWorkoutPlan {
1021
+ /** A UUID; the plan's identity on the watch. */
1022
+ id: string;
1023
+ /** The local calendar day, 'YYYY-MM-DD'. */
1024
+ date: string;
1025
+ /** Local time of day, 07:00 when not given. */
1026
+ hour?: number;
1027
+ minute?: number;
1028
+ workout: AppleCustomWorkout;
1029
+ }
1030
+ /** A plan on the watch's schedule. */
1031
+ export interface ScheduledWorkout {
1032
+ /** Lowercase UUID. */
1033
+ id: string;
1034
+ date: string;
1035
+ hour?: number;
1036
+ minute?: number;
1037
+ /** The person has done it. */
1038
+ complete: boolean;
1039
+ }
1040
+ export interface ScheduleWorkoutResult {
1041
+ id: string;
1042
+ /** Alerts WorkoutKit refused and left out, each by where it was ("block 1 step 2: power alert..."). */
1043
+ droppedAlerts?: string[];
1044
+ }
1045
+ export type ScheduledWorkoutsMethod = 'isSupported' | 'authorization' | 'requestAuthorization' | 'list' | 'schedule' | 'remove' | 'removeAll';
1046
+ /** The rejection every call but isSupported() gets where nothing can schedule. */
1047
+ export declare function scheduledWorkoutsUnsupported(): Error;
1048
+ /** A row of `list()`, or null for one that is not a scheduled plan. */
1049
+ export declare function normalizeScheduledWorkout(value: unknown): ScheduledWorkout | null;
1050
+ export declare const scheduledWorkouts: {
1051
+ /** Whether this app build has the bridge at all (not whether this phone can schedule: `isSupported()`). */
1052
+ isAvailable(): boolean;
1053
+ /** Whether this app build answers `method`; an older build may lack one. */
1054
+ supports(method: ScheduledWorkoutsMethod): boolean;
1055
+ /** iOS 17 or later and WorkoutKit can schedule here; false everywhere else, never rejects. */
1056
+ isSupported(): Promise<boolean>;
1057
+ /** What the person allowed, without asking. */
1058
+ authorization: () => Promise<ScheduledWorkoutsAuthorization>;
1059
+ /** iOS's prompt the first time; its remembered answer after that. Call it from the person's tap. */
1060
+ requestAuthorization: () => Promise<ScheduledWorkoutsAuthorization>;
1061
+ /** Every plan on the watch's schedule. */
1062
+ list: () => Promise<ScheduledWorkout[]>;
1063
+ /**
1064
+ * Put a plan on the watch's schedule (again on another day moves it).
1065
+ * Rejects with a message naming what WorkoutKit refused: an activity or a
1066
+ * goal it cannot schedule (an alert it refuses is dropped instead, listed in
1067
+ * `droppedAlerts`), more plans than the watch holds (LIMIT_REACHED), or a
1068
+ * schedule nothing kept, such as no paired watch (NOT_SCHEDULED).
1069
+ */
1070
+ schedule: (plan: AppleWorkoutPlan) => Promise<ScheduleWorkoutResult>;
1071
+ /** Take a plan off every day it is scheduled; true when there was one. */
1072
+ remove: (id: string) => Promise<boolean>;
1073
+ /** Clear the schedule; how many plans it held. */
1074
+ removeAll: () => Promise<number>;
1075
+ };
940
1076
  export declare const deepLinks: {
941
1077
  getInitialURL(): Promise<string | null>;
942
1078
  /**
@@ -1239,12 +1375,14 @@ export declare const pushNotifications: {
1239
1375
  onReceive(callback: (data: Record<string, unknown>) => void): () => void;
1240
1376
  };
1241
1377
  export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep'
1242
- /** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. Read only. */
1378
+ /** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. */
1243
1379
  | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat'
1244
1380
  /** Mindful minutes (meditation, breathing): read with getMindfulSessions, written with saveMindfulSession. */
1245
1381
  | 'mindful';
1246
1382
  /** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
1247
1383
  export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful'>;
1384
+ /** Quantity types that can be written with `health.saveData()`. */
1385
+ export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful'>;
1248
1386
  export interface HealthAuthorizationOptions {
1249
1387
  /** `false` asks to read only. Default: also allowed to save workouts. */
1250
1388
  write?: boolean;
@@ -1257,6 +1395,10 @@ export interface HealthDataResult {
1257
1395
  value: number;
1258
1396
  unit: string;
1259
1397
  }
1398
+ export interface HealthDataSaveResult {
1399
+ /** HealthKit's UUID for the saved quantity sample. */
1400
+ id: string;
1401
+ }
1260
1402
  export type HealthWorkoutType = 'running' | 'walking' | 'hiking' | 'cycling';
1261
1403
  export interface HealthWorkoutLocation {
1262
1404
  latitude: number;
@@ -1394,7 +1536,7 @@ export interface HealthMindfulSample {
1394
1536
  deviceName?: string;
1395
1537
  }
1396
1538
  /** The health reads and writes a host may or may not have, depending on the app build it runs in. */
1397
- export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveMindfulSession' | 'getMindfulSessions';
1539
+ export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions';
1398
1540
  export declare const health: {
1399
1541
  /**
1400
1542
  * Whether this app build answers `method`. An app installed before a read
@@ -1408,6 +1550,8 @@ export declare const health: {
1408
1550
  */
1409
1551
  requestAuthorization(types: HealthDataType[], options?: HealthAuthorizationOptions): Promise<boolean>;
1410
1552
  getData(type: HealthDataType, options?: HealthDataOptions): Promise<HealthDataResult>;
1553
+ /** Save one quantity sample, such as a meal's dietary energy or macros. iOS only for now. */
1554
+ saveData(type: HealthQuantityType, value: number, options?: HealthDataOptions): Promise<HealthDataSaveResult>;
1411
1555
  saveWorkout(workout: HealthWorkout): Promise<HealthWorkoutResult>;
1412
1556
  /** Workouts in Apple Health, newest first. iOS only for now. */
1413
1557
  getWorkouts(options?: HealthWorkoutQuery): Promise<HealthWorkoutSample[]>;
@@ -1474,6 +1618,7 @@ declare const mobile: {
1474
1618
  speech: typeof speech;
1475
1619
  music: typeof music;
1476
1620
  heartRate: typeof heartRate;
1621
+ scheduledWorkouts: typeof scheduledWorkouts;
1477
1622
  deepLinks: typeof deepLinks;
1478
1623
  network: typeof network;
1479
1624
  appReview: typeof appReview;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.162";
3306
+ var version = "0.0.164";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.162";
313
+ var version = "0.0.164";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.162";
61
+ var version = "0.0.164";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -88,6 +88,17 @@ export interface CraftConfig {
88
88
  * playback session).
89
89
  */
90
90
  enableBackgroundAudio?: boolean;
91
+ /**
92
+ * Schedule structured workouts onto the paired Apple Watch through
93
+ * `craft.scheduledWorkouts` (WorkoutKit's WorkoutScheduler, iOS 17+): each
94
+ * appears in the watch's Workout app on its day, with its steps, goals and
95
+ * alerts. Compiles CraftScheduledWorkouts.swift with the
96
+ * `CRAFT_SCHEDULED_WORKOUTS` condition, so an app without it never links
97
+ * WorkoutKit; below iOS 17 it is weak-linked and answers unsupported. iOS
98
+ * asks the person the first time the page calls
99
+ * `craft.scheduledWorkouts.requestAuthorization()`, never at launch.
100
+ */
101
+ enableScheduledWorkouts?: boolean;
91
102
  /**
92
103
  * Purpose strings in the app's own words, by Info.plist key
93
104
  * (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
@@ -381,6 +392,13 @@ export declare function renderWatchEntitlements(config: CraftConfig): string;
381
392
  * reach everything the bridge actually uses.
382
393
  */
383
394
  export declare function renderRuntimeSettings(): string;
395
+ /**
396
+ * Build settings a feature adds to the app target. WorkoutKit is compiled in
397
+ * only for `enableScheduledWorkouts`: CraftScheduledWorkouts.swift imports it
398
+ * behind the `CRAFT_SCHEDULED_WORKOUTS` condition, so an app that does not
399
+ * schedule workouts does not link it. `$(inherited)` keeps Debug's `DEBUG`.
400
+ */
401
+ export declare function renderFeatureSettings(config: Pick<CraftConfig, 'enableScheduledWorkouts'>): string;
384
402
  export declare function renderPrivacyManifest(config: CraftConfig): string;
385
403
  /**
386
404
  * Whether a PNG carries an alpha channel, from its header: colour type 4
@@ -349,6 +349,7 @@ var DEFAULT_CONFIG = {
349
349
  enableMLKit: false,
350
350
  enableAppleMusic: false,
351
351
  enableHeartRate: false,
352
+ enableScheduledWorkouts: false,
352
353
  iosVersion: "16.0",
353
354
  watchosVersion: "9.0",
354
355
  teamId: "",
@@ -583,6 +584,14 @@ function renderRuntimeSettings() {
583
584
  ].join(`
584
585
  `);
585
586
  }
587
+ function renderFeatureSettings(config) {
588
+ const conditions = [];
589
+ if (config.enableScheduledWorkouts)
590
+ conditions.push("CRAFT_SCHEDULED_WORKOUTS");
591
+ if (!conditions.length)
592
+ return "";
593
+ return ` SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) ${conditions.join(" ")}"`;
594
+ }
586
595
  function renderPrivacyManifest(config) {
587
596
  const privacy = config.privacy ?? {};
588
597
  const collected = privacy.collectedDataTypes ?? [];
@@ -944,6 +953,7 @@ function writeShell(output, config) {
944
953
  cpSync(join2(TEMPLATES_DIR, "CraftHealth.swift"), join2(output, "Sources", "CraftHealth.swift"));
945
954
  cpSync(join2(TEMPLATES_DIR, "CraftMusic.swift"), join2(output, "Sources", "CraftMusic.swift"));
946
955
  cpSync(join2(TEMPLATES_DIR, "CraftHeartRate.swift"), join2(output, "Sources", "CraftHeartRate.swift"));
956
+ cpSync(join2(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join2(output, "Sources", "CraftScheduledWorkouts.swift"));
947
957
  cpSync(join2(TEMPLATES_DIR, "CraftNativeMutation.swift"), join2(output, "Sources", "CraftNativeMutation.swift"));
948
958
  cpSync(join2(TEMPLATES_DIR, "CraftNativeUI.swift"), join2(output, "Sources", "CraftNativeUI.swift"));
949
959
  cpSync(join2(TEMPLATES_DIR, "CraftNativeServices.swift"), join2(output, "Sources", "CraftNativeServices.swift"));
@@ -1065,7 +1075,7 @@ async function init(options) {
1065
1075
  } else {
1066
1076
  rmSync2(join2(output, "Runtime"), { recursive: true, force: true });
1067
1077
  }
1068
- const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
1078
+ const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{FEATURE_SETTINGS\}\}/g, renderFeatureSettings(config)).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
1069
1079
  ${nativeDependencies.join(`
1070
1080
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
1071
1081
  `));
@@ -1298,6 +1308,7 @@ export {
1298
1308
  renderOrientations,
1299
1309
  renderOfflinePage,
1300
1310
  renderLaunchScreen,
1311
+ renderFeatureSettings,
1301
1312
  renderEntitlements,
1302
1313
  renderDeviceFamilies,
1303
1314
  renderBackgroundTaskIdentifiers,
@@ -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) {