@craft-native/ios 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.
- package/README.md +6 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +140 -0
- package/templates/CraftNativeScreen.swift +10 -2
package/README.md
CHANGED
|
@@ -371,6 +371,12 @@ await health.requestAuthorization(['workouts', 'heartRate', 'sleep'], { write: f
|
|
|
371
371
|
const workouts = await health.getWorkouts({ startDate: Date.now() - 30 * 86400000 })
|
|
372
372
|
// One value per local day: sums, averages, the latest weight, hours asleep.
|
|
373
373
|
const hrv = await health.getDailyStatistics('heartRateVariability', { startDate: Date.now() - 30 * 86400000 })
|
|
374
|
+
// Every heart-rate reading in a range (bpm, start/end, source and device), oldest first.
|
|
375
|
+
const readings = await health.getHeartRateSamples({ startDate: Date.now() - 86400000 })
|
|
376
|
+
// The night as Apple Health recorded it: in bed, awake, and each asleep stage.
|
|
377
|
+
const nights = await health.getSleepIntervals({ startDate: Date.now() - 86400000 })
|
|
378
|
+
// An app built before these reads existed has neither; check first.
|
|
379
|
+
if (health.supports('getHeartRateSamples')) { /* ... */ }
|
|
374
380
|
|
|
375
381
|
// Screen Capture
|
|
376
382
|
const screenshotBase64 = await window.craft.takeScreenshot();
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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]
|
|
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,
|