craft-native 0.0.138 → 0.0.140

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.
@@ -1130,7 +1130,57 @@ export interface HealthDailyValue {
1130
1130
  value: number;
1131
1131
  unit: string;
1132
1132
  }
1133
+ export interface HealthSampleQuery {
1134
+ /** Epoch milliseconds. Defaults to a day before `endDate`. */
1135
+ startDate?: number;
1136
+ /** Epoch milliseconds. Defaults to now. */
1137
+ endDate?: number;
1138
+ }
1139
+ export interface HealthHeartRateQuery extends HealthSampleQuery {
1140
+ /** At most this many, oldest first. Defaults to 10000, capped at 50000. */
1141
+ limit?: number;
1142
+ }
1143
+ /** One heart-rate reading from Apple Health, in beats per minute. */
1144
+ export interface HealthHeartRateSample {
1145
+ bpm: number;
1146
+ /** Epoch milliseconds. */
1147
+ startDate: number;
1148
+ /** Epoch milliseconds; the same as `startDate` for a single reading. */
1149
+ endDate: number;
1150
+ /** The app that wrote it, e.g. the watch's own name. */
1151
+ sourceName: string;
1152
+ /** The device that measured it, when the source says ("Apple Watch"). */
1153
+ deviceName?: string;
1154
+ deviceModel?: string;
1155
+ /** A series of readings (a watch workout) also carries its count, lowest and highest. */
1156
+ count?: number;
1157
+ min?: number;
1158
+ max?: number;
1159
+ /** What the wearer was doing, when the watch says. */
1160
+ motionContext?: 'sedentary' | 'active';
1161
+ }
1162
+ /** A sleep analysis stage: in bed, awake, or asleep (staged or not). */
1163
+ export type HealthSleepStage = 'inBed' | 'awake' | 'asleep' | 'core' | 'deep' | 'rem';
1164
+ /** One stretch of a night as a source recorded it. */
1165
+ export interface HealthSleepInterval {
1166
+ /** Epoch milliseconds. */
1167
+ startDate: number;
1168
+ /** Epoch milliseconds. */
1169
+ endDate: number;
1170
+ stage: HealthSleepStage;
1171
+ /** Every asleep stage; false for time in bed and time awake. */
1172
+ asleep: boolean;
1173
+ sourceName: string;
1174
+ deviceName?: string;
1175
+ }
1176
+ /** The health reads a host may or may not have, depending on the app build it runs in. */
1177
+ export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals';
1133
1178
  export declare const health: {
1179
+ /**
1180
+ * Whether this app build answers `method`. An app installed before a read
1181
+ * existed has none, so a page checks before offering what needs it.
1182
+ */
1183
+ supports(method: HealthReadMethod): boolean;
1134
1184
  /**
1135
1185
  * Ask for the health data a page needs. `{ write: false }` asks to read
1136
1186
  * only, for an app that never writes back; by default it may also save
@@ -1143,6 +1193,10 @@ export declare const health: {
1143
1193
  getWorkouts(options?: HealthWorkoutQuery): Promise<HealthWorkoutSample[]>;
1144
1194
  /** One value per local day between two dates. iOS only for now. */
1145
1195
  getDailyStatistics(type: HealthDailyType, options?: HealthDataOptions): Promise<HealthDailyValue[]>;
1196
+ /** Every heart-rate reading between two instants, oldest first. iOS only for now. */
1197
+ getHeartRateSamples(options?: HealthHeartRateQuery): Promise<HealthHeartRateSample[]>;
1198
+ /** Sleep between two instants, oldest first: time in bed and each asleep or awake stage. iOS only for now. */
1199
+ getSleepIntervals(options?: HealthSampleQuery): Promise<HealthSleepInterval[]>;
1146
1200
  };
1147
1201
  export interface LiveActivityState {
1148
1202
  status?: string;
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.138";
3306
+ var version = "0.0.140";
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.138";
313
+ var version = "0.0.140";
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.138";
61
+ var version = "0.0.140";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -2358,6 +2358,27 @@ struct CraftWebView: UIViewRepresentable {
2358
2358
  } else {
2359
2359
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2360
2360
  }
2361
+ case "getHealthHeartRateSamples":
2362
+ if config.enableHealthKit {
2363
+ getHealthHeartRateSamples(
2364
+ startDate: body["startDate"] as? Double,
2365
+ endDate: body["endDate"] as? Double,
2366
+ limit: body["limit"] as? Int,
2367
+ callbackId: callbackId
2368
+ )
2369
+ } else {
2370
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2371
+ }
2372
+ case "getHealthSleepIntervals":
2373
+ if config.enableHealthKit {
2374
+ getHealthSleepIntervals(
2375
+ startDate: body["startDate"] as? Double,
2376
+ endDate: body["endDate"] as? Double,
2377
+ callbackId: callbackId
2378
+ )
2379
+ } else {
2380
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2381
+ }
2361
2382
 
2362
2383
  // MARK: - Live Activities
2363
2384
  case "startLiveActivity":
@@ -4854,6 +4875,14 @@ struct CraftWebView: UIViewRepresentable {
4854
4875
  getDailyStatistics: function(type, options) {
4855
4876
  options = options || {};
4856
4877
  return craft._invoke('getHealthDailyStatistics', {type: type, startDate: options.startDate, endDate: options.endDate});
4878
+ },
4879
+ getHeartRateSamples: function(options) {
4880
+ options = options || {};
4881
+ return craft._invoke('getHealthHeartRateSamples', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
4882
+ },
4883
+ getSleepIntervals: function(options) {
4884
+ options = options || {};
4885
+ return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
4857
4886
  }
4858
4887
  };
4859
4888
  craft.liveActivity = {
@@ -7260,6 +7289,117 @@ struct CraftWebView: UIViewRepresentable {
7260
7289
  healthStore.execute(query)
7261
7290
  }
7262
7291
 
7292
+ /// Every heart-rate reading between two instants, oldest first: the
7293
+ /// beats per minute, when it was taken, and the app and device that
7294
+ /// wrote it. A watch's workout readings can arrive as one series
7295
+ /// sample; those also carry their count, lowest and highest.
7296
+ private func getHealthHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
7297
+ guard let healthStore = healthStore else {
7298
+ rejectCallback(callbackId, error: "HealthKit not available")
7299
+ return
7300
+ }
7301
+ guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
7302
+ rejectCallback(callbackId, error: "Heart rate is unavailable")
7303
+ return
7304
+ }
7305
+
7306
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
7307
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -1, to: end)!
7308
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
7309
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
7310
+ let cap = max(1, min(limit ?? 10_000, 50_000))
7311
+
7312
+ let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
7313
+ if let error = error, (error as? HKError)?.code != .errorNoData {
7314
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
7315
+ return
7316
+ }
7317
+
7318
+ let bpm = HKUnit.count().unitDivided(by: .minute())
7319
+ let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
7320
+ var item: [String: Any] = [
7321
+ "bpm": sample.quantity.doubleValue(for: bpm),
7322
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
7323
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
7324
+ "sourceName": sample.sourceRevision.source.name,
7325
+ ]
7326
+ if let device = sample.device?.name { item["deviceName"] = device }
7327
+ if let model = sample.device?.model { item["deviceModel"] = model }
7328
+ if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
7329
+ item["count"] = series.count
7330
+ item["min"] = series.minimumQuantity.doubleValue(for: bpm)
7331
+ item["max"] = series.maximumQuantity.doubleValue(for: bpm)
7332
+ }
7333
+ if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
7334
+ switch HKHeartRateMotionContext(rawValue: raw.intValue) {
7335
+ case .sedentary: item["motionContext"] = "sedentary"
7336
+ case .active: item["motionContext"] = "active"
7337
+ default: break
7338
+ }
7339
+ }
7340
+ return item
7341
+ }
7342
+ self?.resolveCallback(callbackId, result: rows)
7343
+ }
7344
+ healthStore.execute(query)
7345
+ }
7346
+
7347
+ /// A stable name for each sleep analysis value: in bed, awake, or one
7348
+ /// of the asleep stages (`asleep` when the source does not stage it).
7349
+ private static func sleepStageName(_ value: Int) -> String {
7350
+ switch HKCategoryValueSleepAnalysis(rawValue: value) {
7351
+ case .inBed: return "inBed"
7352
+ case .awake: return "awake"
7353
+ case .asleepCore: return "core"
7354
+ case .asleepDeep: return "deep"
7355
+ case .asleepREM: return "rem"
7356
+ default: return "asleep"
7357
+ }
7358
+ }
7359
+
7360
+ /// Sleep between two instants, oldest first, as the sources wrote it:
7361
+ /// time in bed and every asleep or awake stretch, each with its stage
7362
+ /// and whether it counts as asleep, so a page can mark the night.
7363
+ private func getHealthSleepIntervals(startDate: Double?, endDate: Double?, callbackId: String?) {
7364
+ guard let healthStore = healthStore else {
7365
+ rejectCallback(callbackId, error: "HealthKit not available")
7366
+ return
7367
+ }
7368
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
7369
+ rejectCallback(callbackId, error: "Sleep data is unavailable")
7370
+ return
7371
+ }
7372
+
7373
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
7374
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -1, to: end)!
7375
+ // Any interval that overlaps the range, so a night that began
7376
+ // before it still counts.
7377
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
7378
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
7379
+
7380
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
7381
+ if let error = error, (error as? HKError)?.code != .errorNoData {
7382
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
7383
+ return
7384
+ }
7385
+
7386
+ let notAsleep: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
7387
+ let rows: [[String: Any]] = (samples as? [HKCategorySample] ?? []).map { sample in
7388
+ var item: [String: Any] = [
7389
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
7390
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
7391
+ "stage": Self.sleepStageName(sample.value),
7392
+ "asleep": !notAsleep.contains(sample.value),
7393
+ "sourceName": sample.sourceRevision.source.name,
7394
+ ]
7395
+ if let device = sample.device?.name { item["deviceName"] = device }
7396
+ return item
7397
+ }
7398
+ self?.resolveCallback(callbackId, result: rows)
7399
+ }
7400
+ healthStore.execute(query)
7401
+ }
7402
+
7263
7403
  // MARK: - Live Activities
7264
7404
  private func startLiveActivity(body: [String: Any], callbackId: String?) {
7265
7405
  guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
@@ -1310,8 +1310,11 @@ private final class CraftNativeGradientLayer: CAGradientLayer {
1310
1310
  layer.follow(view)
1311
1311
  guard existing == nil else { return }
1312
1312
  view.layer.insertSublayer(layer, at: 0)
1313
+ // The layer's bounds, not the view's: UIKit does not promise KVO for
1314
+ // UIView.bounds, and a screen laid out by frame never reported it, so
1315
+ // every gradient stayed zero-sized. CALayer's own bounds always do.
1313
1316
  layer.observations = [
1314
- view.observe(\.bounds) { [weak layer] view, _ in layer?.follow(view) },
1317
+ view.layer.observe(\.bounds) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
1315
1318
  view.layer.observe(\.cornerRadius) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
1316
1319
  ]
1317
1320
  }
@@ -2744,7 +2747,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2744
2747
  return textView
2745
2748
  }
2746
2749
  let field = UITextField()
2747
- field.borderStyle = .roundedRect
2748
2750
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
2749
2751
  field.addTarget(self, action: #selector(textFocused(_:)), for: .editingDidBegin)
2750
2752
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
@@ -3282,6 +3284,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3282
3284
  updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
3283
3285
  updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
3284
3286
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
3287
+ // A field the style dresses itself (`border-0`, a border or a
3288
+ // background, as a search field inside its own pill is) draws no
3289
+ // system box of its own; a bare one keeps the rounded rectangle.
3290
+ if let field = view as? UITextField {
3291
+ field.borderStyle = style["borderWidth"] != nil || style["backgroundColor"] != nil ? .none : .roundedRect
3292
+ }
3285
3293
  CraftNativeGradientLayer.apply(
3286
3294
  to: view,
3287
3295
  direction: style["gradientDirection"] as? String,
package/dist/mobile.js CHANGED
@@ -1036,6 +1036,9 @@ var pushNotifications = {
1036
1036
  }
1037
1037
  };
1038
1038
  var health = {
1039
+ supports(method) {
1040
+ return typeof getCraftRoot()?.health?.[method] === "function";
1041
+ },
1039
1042
  async requestAuthorization(types, options = {}) {
1040
1043
  const craft = getCraftRoot();
1041
1044
  if (!craft?.health?.requestAuthorization)
@@ -1065,6 +1068,18 @@ var health = {
1065
1068
  if (!craft?.health?.getDailyStatistics)
1066
1069
  throw new Error("Daily health statistics are unavailable");
1067
1070
  return craft.health.getDailyStatistics(type, options);
1071
+ },
1072
+ async getHeartRateSamples(options = {}) {
1073
+ const craft = getCraftRoot();
1074
+ if (!craft?.health?.getHeartRateSamples)
1075
+ throw new Error("Reading heart-rate samples is unavailable");
1076
+ return craft.health.getHeartRateSamples(options);
1077
+ },
1078
+ async getSleepIntervals(options = {}) {
1079
+ const craft = getCraftRoot();
1080
+ if (!craft?.health?.getSleepIntervals)
1081
+ throw new Error("Reading sleep intervals is unavailable");
1082
+ return craft.health.getSleepIntervals(options);
1068
1083
  }
1069
1084
  };
1070
1085
  async function updateLiveActivity(idOrState, state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.138",
3
+ "version": "0.0.140",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",