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.
- package/dist/api/mobile.d.ts +54 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +140 -0
- package/dist/ios/templates/CraftNativeScreen.swift +10 -2
- package/dist/mobile.js +15 -0
- package/package.json +1 -1
package/dist/api/mobile.d.ts
CHANGED
|
@@ -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.
|
|
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
package/dist/index.js
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,
|
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) {
|