craft-native 0.0.136 → 0.0.139
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/src/index.js +6 -0
- package/dist/ios/templates/CraftApp.swift +156 -4
- package/dist/ios/templates/CraftNativeScreen.swift +275 -7
- 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.139";
|
|
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
package/dist/ios/src/index.js
CHANGED
|
@@ -349,6 +349,12 @@ ${plistArray(config.appGroups)}
|
|
|
349
349
|
entries.push(` <key>aps-environment</key>
|
|
350
350
|
<string>$(CRAFT_APNS_ENVIRONMENT)</string>`);
|
|
351
351
|
}
|
|
352
|
+
if (config.enableSocialAuth) {
|
|
353
|
+
entries.push(` <key>com.apple.developer.applesignin</key>
|
|
354
|
+
<array>
|
|
355
|
+
<string>Default</string>
|
|
356
|
+
</array>`);
|
|
357
|
+
}
|
|
352
358
|
if (config.enableSecureStorage) {
|
|
353
359
|
entries.push(` <key>keychain-access-groups</key>
|
|
354
360
|
<array>
|
|
@@ -2225,7 +2225,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2225
2225
|
// MARK: - Social Auth
|
|
2226
2226
|
case "signInWithApple":
|
|
2227
2227
|
if config.enableSocialAuth {
|
|
2228
|
-
signInWithApple(callbackId: callbackId)
|
|
2228
|
+
signInWithApple(nonce: body["nonce"] as? String, callbackId: callbackId)
|
|
2229
2229
|
} else {
|
|
2230
2230
|
rejectCallback(callbackId, error: "Social sign-in is disabled", code: "CAPABILITY_DISABLED")
|
|
2231
2231
|
}
|
|
@@ -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":
|
|
@@ -3584,8 +3605,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3584
3605
|
restorePurchases: function() {
|
|
3585
3606
|
return this._invoke('restorePurchases');
|
|
3586
3607
|
},
|
|
3587
|
-
|
|
3588
|
-
|
|
3608
|
+
// `nonce` is the SHA-256 of a one-time value the page keeps;
|
|
3609
|
+
// Apple writes it into the identity token, so the server can
|
|
3610
|
+
// tie the sign-in to that page. The answer carries Apple's
|
|
3611
|
+
// one-use authorizationCode for the server to redeem.
|
|
3612
|
+
signInWithApple: function(options) {
|
|
3613
|
+
var nonce = options && typeof options.nonce === 'string' ? options.nonce : undefined;
|
|
3614
|
+
return this._invoke('signInWithApple', nonce ? {nonce: nonce} : {});
|
|
3589
3615
|
},
|
|
3590
3616
|
signInWithGoogle: function() {
|
|
3591
3617
|
return Promise.reject(new Error('Google Sign-In is unavailable on iOS'));
|
|
@@ -4849,6 +4875,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4849
4875
|
getDailyStatistics: function(type, options) {
|
|
4850
4876
|
options = options || {};
|
|
4851
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});
|
|
4852
4886
|
}
|
|
4853
4887
|
};
|
|
4854
4888
|
craft.liveActivity = {
|
|
@@ -6605,12 +6639,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6605
6639
|
}
|
|
6606
6640
|
|
|
6607
6641
|
// MARK: - Social Auth (Apple Sign In)
|
|
6608
|
-
private func signInWithApple(callbackId: String?) {
|
|
6642
|
+
private func signInWithApple(nonce: String?, callbackId: String?) {
|
|
6609
6643
|
DispatchQueue.main.async {
|
|
6610
6644
|
self.pendingCallbackId = callbackId
|
|
6611
6645
|
let provider = ASAuthorizationAppleIDProvider()
|
|
6612
6646
|
let request = provider.createRequest()
|
|
6613
6647
|
request.requestedScopes = [.fullName, .email]
|
|
6648
|
+
if let nonce, !nonce.isEmpty { request.nonce = nonce }
|
|
6614
6649
|
|
|
6615
6650
|
let controller = ASAuthorizationController(authorizationRequests: [request])
|
|
6616
6651
|
controller.delegate = self
|
|
@@ -7254,6 +7289,117 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7254
7289
|
healthStore.execute(query)
|
|
7255
7290
|
}
|
|
7256
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
|
+
|
|
7257
7403
|
// MARK: - Live Activities
|
|
7258
7404
|
private func startLiveActivity(body: [String: Any], callbackId: String?) {
|
|
7259
7405
|
guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
|
|
@@ -8233,6 +8379,12 @@ extension CraftWebView.Coordinator: ASAuthorizationControllerDelegate, ASAuthori
|
|
|
8233
8379
|
let tokenString = String(data: identityToken, encoding: .utf8) {
|
|
8234
8380
|
result["identityToken"] = tokenString
|
|
8235
8381
|
}
|
|
8382
|
+
// One use, five minutes: the server redeems it at Apple's token
|
|
8383
|
+
// endpoint and so reads the identity straight from Apple.
|
|
8384
|
+
if let authorizationCode = appleIDCredential.authorizationCode,
|
|
8385
|
+
let codeString = String(data: authorizationCode, encoding: .utf8) {
|
|
8386
|
+
result["authorizationCode"] = codeString
|
|
8387
|
+
}
|
|
8236
8388
|
|
|
8237
8389
|
resolveCallback(pendingCallbackId, result: result)
|
|
8238
8390
|
}
|
|
@@ -1127,6 +1127,249 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
1127
1127
|
|
|
1128
1128
|
/// Text with padding of its own: the padding is inside the label, around
|
|
1129
1129
|
/// the text, and counts in its measured size.
|
|
1130
|
+
/// A vector drawing (stx `svg.ts`): an `Svg` node and the shapes below it,
|
|
1131
|
+
/// replayed from each shape's `geometry` commands - 0 move, 1 line, 2 cubic,
|
|
1132
|
+
/// 3 quadratic, 4 close, in viewBox units - and scaled to the view. Paint is
|
|
1133
|
+
/// inherited down the tree as SVG does; `dark:` classes arrive already
|
|
1134
|
+
/// resolved for the appearance, so a redraw is all a theme change needs.
|
|
1135
|
+
private final class CraftNativeSvgView: UIView {
|
|
1136
|
+
var drawing: [String: Any] = [:] {
|
|
1137
|
+
didSet { setNeedsDisplay() }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
override init(frame: CGRect) {
|
|
1141
|
+
super.init(frame: frame)
|
|
1142
|
+
isOpaque = false
|
|
1143
|
+
backgroundColor = .clear
|
|
1144
|
+
contentMode = .redraw
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
1148
|
+
|
|
1149
|
+
var viewBox: CGRect? {
|
|
1150
|
+
let props = drawing["props"] as? [String: Any] ?? [:]
|
|
1151
|
+
guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
|
|
1152
|
+
let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
1153
|
+
guard n.count == 4, n[2] > 0, n[3] > 0 else { return nil }
|
|
1154
|
+
return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) }
|
|
1158
|
+
|
|
1159
|
+
/// Height from the viewBox's proportions, when no style gave one.
|
|
1160
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
1161
|
+
guard let box = viewBox, size.width > 0, size.width < CraftNativeFlexLayout.unbounded else { return CGSize(width: 0, height: 0) }
|
|
1162
|
+
return CGSize(width: size.width, height: size.width * box.height / box.width)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private struct Paint {
|
|
1166
|
+
var fill: String? = "#000000"
|
|
1167
|
+
var stroke: String?
|
|
1168
|
+
var strokeWidth: CGFloat = 1
|
|
1169
|
+
var color: String? = "#000000"
|
|
1170
|
+
var opacity: CGFloat = 1
|
|
1171
|
+
var linecap: String?
|
|
1172
|
+
var linejoin: String?
|
|
1173
|
+
var dashes: [CGFloat]?
|
|
1174
|
+
|
|
1175
|
+
func merged(_ node: [String: Any]) -> Paint {
|
|
1176
|
+
var next = self
|
|
1177
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
1178
|
+
let style = node["style"] as? [String: Any] ?? [:]
|
|
1179
|
+
if let color = style["color"] as? String { next.color = color }
|
|
1180
|
+
if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
|
|
1181
|
+
if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
|
|
1182
|
+
if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
|
|
1183
|
+
if let opacity = props["opacity"] as? NSNumber ?? style["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
|
|
1184
|
+
if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
|
|
1185
|
+
if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
|
|
1186
|
+
if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
|
|
1187
|
+
return next
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
func resolve(_ value: String?) -> UIColor? {
|
|
1191
|
+
guard let value, value != "none", value != "transparent" else { return nil }
|
|
1192
|
+
let text = value == "currentColor" ? (color ?? "#000000") : value
|
|
1193
|
+
return UIColor(css: text) ?? UIColor(hex: text)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
override func draw(_ rect: CGRect) {
|
|
1198
|
+
guard let context = UIGraphicsGetCurrentContext() else { return }
|
|
1199
|
+
if let box = viewBox {
|
|
1200
|
+
let scale = min(bounds.width / box.width, bounds.height / box.height)
|
|
1201
|
+
guard scale.isFinite, scale > 0 else { return }
|
|
1202
|
+
context.translateBy(x: (bounds.width - box.width * scale) / 2, y: (bounds.height - box.height * scale) / 2)
|
|
1203
|
+
context.scaleBy(x: scale, y: scale)
|
|
1204
|
+
context.translateBy(x: -box.minX, y: -box.minY)
|
|
1205
|
+
}
|
|
1206
|
+
draw(children: drawing["children"] as? [Any] ?? [], paint: Paint().merged(drawing), in: context)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private func draw(children: [Any], paint: Paint, in context: CGContext) {
|
|
1210
|
+
for case let shape as [String: Any] in children {
|
|
1211
|
+
let style = shape["style"] as? [String: Any] ?? [:]
|
|
1212
|
+
if style["display"] as? String == "none" { continue }
|
|
1213
|
+
let own = paint.merged(shape)
|
|
1214
|
+
switch shape["type"] as? String {
|
|
1215
|
+
case "G":
|
|
1216
|
+
draw(children: shape["children"] as? [Any] ?? [], paint: own, in: context)
|
|
1217
|
+
case "SvgText":
|
|
1218
|
+
drawText(shape, paint: own)
|
|
1219
|
+
default:
|
|
1220
|
+
guard let path = CraftNativeSvgView.path((shape["props"] as? [String: Any])?["geometry"]) else { continue }
|
|
1221
|
+
context.saveGState()
|
|
1222
|
+
context.setAlpha(own.opacity)
|
|
1223
|
+
if let fill = own.resolve(own.fill) {
|
|
1224
|
+
fill.setFill()
|
|
1225
|
+
path.fill()
|
|
1226
|
+
}
|
|
1227
|
+
if let stroke = own.resolve(own.stroke), own.strokeWidth > 0 {
|
|
1228
|
+
stroke.setStroke()
|
|
1229
|
+
path.lineWidth = own.strokeWidth
|
|
1230
|
+
path.lineCapStyle = own.linecap == "round" ? .round : own.linecap == "square" ? .square : .butt
|
|
1231
|
+
path.lineJoinStyle = own.linejoin == "round" ? .round : own.linejoin == "bevel" ? .bevel : .miter
|
|
1232
|
+
if let dashes = own.dashes, !dashes.isEmpty { path.setLineDash(dashes, count: dashes.count, phase: 0) }
|
|
1233
|
+
path.stroke()
|
|
1234
|
+
}
|
|
1235
|
+
context.restoreGState()
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private func drawText(_ node: [String: Any], paint: Paint) {
|
|
1241
|
+
let text = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
1242
|
+
guard !text.isEmpty else { return }
|
|
1243
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
1244
|
+
let style = node["style"] as? [String: Any] ?? [:]
|
|
1245
|
+
let size = CGFloat(truncating: props["fontSize"] as? NSNumber ?? style["fontSize"] as? NSNumber ?? 12)
|
|
1246
|
+
let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
|
|
1247
|
+
let string = NSAttributedString(string: text, attributes: [
|
|
1248
|
+
.font: UIFont.systemFont(ofSize: size, weight: weight),
|
|
1249
|
+
.foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
|
|
1250
|
+
])
|
|
1251
|
+
var x = CGFloat(truncating: props["x"] as? NSNumber ?? 0)
|
|
1252
|
+
let y = CGFloat(truncating: props["y"] as? NSNumber ?? 0)
|
|
1253
|
+
switch props["textAnchor"] as? String {
|
|
1254
|
+
case "middle": x -= string.size().width / 2
|
|
1255
|
+
case "end": x -= string.size().width
|
|
1256
|
+
default: break
|
|
1257
|
+
}
|
|
1258
|
+
// y is the baseline, as in SVG.
|
|
1259
|
+
string.draw(at: CGPoint(x: x, y: y - UIFont.systemFont(ofSize: size).ascender))
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
static func path(_ value: Any?) -> UIBezierPath? {
|
|
1263
|
+
guard let list = value as? [Any] else { return nil }
|
|
1264
|
+
let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
1265
|
+
guard !n.isEmpty, n.count == list.count else { return nil }
|
|
1266
|
+
let path = UIBezierPath()
|
|
1267
|
+
var i = 0
|
|
1268
|
+
func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
|
|
1269
|
+
while i < n.count {
|
|
1270
|
+
switch Int(n[i]) {
|
|
1271
|
+
case 0 where i + 2 < n.count: path.move(to: point(i + 1)); i += 3
|
|
1272
|
+
case 1 where i + 2 < n.count: path.addLine(to: point(i + 1)); i += 3
|
|
1273
|
+
case 2 where i + 6 < n.count: path.addCurve(to: point(i + 5), controlPoint1: point(i + 1), controlPoint2: point(i + 3)); i += 7
|
|
1274
|
+
case 3 where i + 4 < n.count: path.addQuadCurve(to: point(i + 3), controlPoint: point(i + 1)); i += 5
|
|
1275
|
+
case 4: path.close(); i += 1
|
|
1276
|
+
default: return path
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return path
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/// A view's linear gradient (stx `bg-gradient-to-br from-* via-* to-*`), kept
|
|
1284
|
+
/// as its first sublayer - under its subviews - and following its bounds and
|
|
1285
|
+
/// corners as they change.
|
|
1286
|
+
private final class CraftNativeGradientLayer: CAGradientLayer {
|
|
1287
|
+
private var observations: [NSKeyValueObservation] = []
|
|
1288
|
+
|
|
1289
|
+
static func apply(to view: UIView, direction: String?, colors: [UIColor?]) {
|
|
1290
|
+
let existing = view.layer.sublayers?.first as? CraftNativeGradientLayer
|
|
1291
|
+
guard let direction, let from = colors[0], let to = colors[2] else {
|
|
1292
|
+
existing?.removeFromSuperlayer()
|
|
1293
|
+
return
|
|
1294
|
+
}
|
|
1295
|
+
let layer = existing ?? CraftNativeGradientLayer()
|
|
1296
|
+
layer.colors = [from, colors[1], to].compactMap { $0?.cgColor }
|
|
1297
|
+
let points: (CGPoint, CGPoint)
|
|
1298
|
+
switch direction {
|
|
1299
|
+
case "to-t": points = (CGPoint(x: 0.5, y: 1), CGPoint(x: 0.5, y: 0))
|
|
1300
|
+
case "to-tr": points = (CGPoint(x: 0, y: 1), CGPoint(x: 1, y: 0))
|
|
1301
|
+
case "to-r": points = (CGPoint(x: 0, y: 0.5), CGPoint(x: 1, y: 0.5))
|
|
1302
|
+
case "to-br": points = (CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1))
|
|
1303
|
+
case "to-bl": points = (CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1))
|
|
1304
|
+
case "to-l": points = (CGPoint(x: 1, y: 0.5), CGPoint(x: 0, y: 0.5))
|
|
1305
|
+
case "to-tl": points = (CGPoint(x: 1, y: 1), CGPoint(x: 0, y: 0))
|
|
1306
|
+
default: points = (CGPoint(x: 0.5, y: 0), CGPoint(x: 0.5, y: 1))
|
|
1307
|
+
}
|
|
1308
|
+
layer.startPoint = points.0
|
|
1309
|
+
layer.endPoint = points.1
|
|
1310
|
+
layer.follow(view)
|
|
1311
|
+
guard existing == nil else { return }
|
|
1312
|
+
view.layer.insertSublayer(layer, at: 0)
|
|
1313
|
+
layer.observations = [
|
|
1314
|
+
view.observe(\.bounds) { [weak layer] view, _ in layer?.follow(view) },
|
|
1315
|
+
view.layer.observe(\.cornerRadius) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
|
|
1316
|
+
]
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private func follow(_ view: UIView) {
|
|
1320
|
+
CATransaction.begin()
|
|
1321
|
+
CATransaction.setDisableActions(true)
|
|
1322
|
+
frame = view.bounds
|
|
1323
|
+
cornerRadius = view.layer.cornerRadius
|
|
1324
|
+
maskedCorners = view.layer.maskedCorners
|
|
1325
|
+
masksToBounds = cornerRadius > 0
|
|
1326
|
+
CATransaction.commit()
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/// A button that keeps the padding its style asks for (`px-4 py-2`). A bare
|
|
1331
|
+
/// UIButton sizes to its title and ignores it, so a styled button drew as a
|
|
1332
|
+
/// pill with its label touching the edges. The padding is added to its size
|
|
1333
|
+
/// and the label laid out inside it, at the alignment the style asked for.
|
|
1334
|
+
private final class CraftNativeButton: UIButton {
|
|
1335
|
+
var padding: UIEdgeInsets = .zero {
|
|
1336
|
+
didSet {
|
|
1337
|
+
guard padding != oldValue else { return }
|
|
1338
|
+
invalidateIntrinsicContentSize()
|
|
1339
|
+
setNeedsLayout()
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
override var intrinsicContentSize: CGSize {
|
|
1344
|
+
let size = super.intrinsicContentSize
|
|
1345
|
+
return CGSize(width: size.width + padding.left + padding.right, height: size.height + padding.top + padding.bottom)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
1349
|
+
let inner = super.sizeThatFits(CGSize(
|
|
1350
|
+
width: max(0, size.width - padding.left - padding.right),
|
|
1351
|
+
height: max(0, size.height - padding.top - padding.bottom)
|
|
1352
|
+
))
|
|
1353
|
+
return CGSize(width: inner.width + padding.left + padding.right, height: inner.height + padding.top + padding.bottom)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
override func layoutSubviews() {
|
|
1357
|
+
super.layoutSubviews()
|
|
1358
|
+
guard padding != .zero, let label = titleLabel else { return }
|
|
1359
|
+
let area = bounds.inset(by: padding)
|
|
1360
|
+
let fitted = label.sizeThatFits(area.size)
|
|
1361
|
+
let width = min(ceil(fitted.width), area.width)
|
|
1362
|
+
let height = min(ceil(fitted.height), area.height)
|
|
1363
|
+
let x: CGFloat
|
|
1364
|
+
switch contentHorizontalAlignment {
|
|
1365
|
+
case .left, .leading: x = area.minX
|
|
1366
|
+
case .right, .trailing: x = area.maxX - width
|
|
1367
|
+
default: x = area.midX - width / 2
|
|
1368
|
+
}
|
|
1369
|
+
label.frame = CGRect(x: x, y: area.midY - height / 2, width: width, height: height)
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1130
1373
|
private final class CraftNativeLabel: UILabel {
|
|
1131
1374
|
var insets = UIEdgeInsets.zero {
|
|
1132
1375
|
didSet {
|
|
@@ -1283,6 +1526,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1283
1526
|
private weak var lastFocusedInput: UIView?
|
|
1284
1527
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
1285
1528
|
private var flatListOwners: [String: String] = [:]
|
|
1529
|
+
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
1530
|
+
private var svgOwners: [String: String] = [:]
|
|
1286
1531
|
private let mutationDocument = CraftNativeMutationDocument()
|
|
1287
1532
|
private let capabilityScope = UUID().uuidString
|
|
1288
1533
|
private var pendingCapabilityRequests = Set<String>()
|
|
@@ -1778,13 +2023,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1778
2023
|
button.addAction(action, for: .touchUpInside)
|
|
1779
2024
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
1780
2025
|
NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 44), button.heightAnchor.constraint(equalToConstant: 44)])
|
|
2026
|
+
// The system's own fills and blue, as Apple's apps draw a bar's round
|
|
2027
|
+
// buttons: they follow light, dark and Increase Contrast by themselves.
|
|
1781
2028
|
let accent = spec["variant"] as? String == "accent"
|
|
1782
|
-
button.backgroundColor = UIColor
|
|
1783
|
-
if traits.userInterfaceStyle == .dark { return UIColor(red: 0.15, green: 0.39, blue: 0.92, alpha: accent ? 0.24 : 0.12) }
|
|
1784
|
-
return UIColor(red: 0.15, green: 0.39, blue: 0.92, alpha: 0.08)
|
|
1785
|
-
}
|
|
2029
|
+
button.backgroundColor = accent ? UIColor.systemBlue.withAlphaComponent(0.15) : .tertiarySystemFill
|
|
1786
2030
|
button.layer.cornerRadius = 22
|
|
1787
|
-
button.tintColor =
|
|
2031
|
+
button.tintColor = .systemBlue
|
|
1788
2032
|
button.setTitleColor(button.tintColor, for: .normal)
|
|
1789
2033
|
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
1790
2034
|
if let title = spec["title"] as? String, !title.isEmpty { button.setTitle(title, for: .normal) }
|
|
@@ -2118,7 +2362,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2118
2362
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
2119
2363
|
var applied = Set<String>()
|
|
2120
2364
|
for id in ids {
|
|
2121
|
-
let target = flatListOwners[id] ?? id
|
|
2365
|
+
let target = flatListOwners[id] ?? svgOwners[id] ?? id
|
|
2122
2366
|
guard applied.insert(target).inserted else { continue }
|
|
2123
2367
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
2124
2368
|
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
@@ -2228,6 +2472,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2228
2472
|
attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
|
|
2229
2473
|
), for: .normal)
|
|
2230
2474
|
button.contentHorizontalAlignment = buttonAlignment(style["textAlign"])
|
|
2475
|
+
(button as? CraftNativeButton)?.padding = paddingInsets(style)
|
|
2231
2476
|
button.isEnabled = props["disabled"] as? Bool != true
|
|
2232
2477
|
updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
|
|
2233
2478
|
case "TextInput":
|
|
@@ -2388,6 +2633,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2388
2633
|
} else {
|
|
2389
2634
|
indicator.startAnimating()
|
|
2390
2635
|
}
|
|
2636
|
+
case "Svg":
|
|
2637
|
+
let drawing = result as! CraftNativeSvgView
|
|
2638
|
+
let owner = current.protocolId ?? path
|
|
2639
|
+
svgOwners = svgOwners.filter { $0.value != owner }
|
|
2640
|
+
for child in children { registerSvgOwnership(child, owner: owner) }
|
|
2641
|
+
let previousBox = drawing.viewBox
|
|
2642
|
+
drawing.drawing = node
|
|
2643
|
+
if drawing.viewBox != previousBox { CraftNativeFlexLayout.invalidateAncestors(of: drawing) }
|
|
2644
|
+
current.children = []
|
|
2391
2645
|
case "Icon":
|
|
2392
2646
|
configureIcon(result as! UIImageView, props: props, style: style)
|
|
2393
2647
|
case "Image":
|
|
@@ -2480,7 +2734,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2480
2734
|
label.numberOfLines = 0
|
|
2481
2735
|
return label
|
|
2482
2736
|
case "Button", "Link":
|
|
2483
|
-
let button =
|
|
2737
|
+
let button = CraftNativeButton(type: .system)
|
|
2484
2738
|
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
2485
2739
|
return button
|
|
2486
2740
|
case "TextInput":
|
|
@@ -2524,6 +2778,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2524
2778
|
return CraftNativeScrollView()
|
|
2525
2779
|
case "FlatList":
|
|
2526
2780
|
return CraftNativeFlatList()
|
|
2781
|
+
case "Svg":
|
|
2782
|
+
return CraftNativeSvgView()
|
|
2527
2783
|
case "View", "SafeAreaView":
|
|
2528
2784
|
return CraftNativeFlowView()
|
|
2529
2785
|
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
@@ -2689,6 +2945,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2689
2945
|
}
|
|
2690
2946
|
}
|
|
2691
2947
|
|
|
2948
|
+
private func registerSvgOwnership(_ node: Any, owner: String) {
|
|
2949
|
+
guard let node = node as? [String: Any] else { return }
|
|
2950
|
+
if let id = node["id"] as? String, !id.isEmpty { svgOwners[id] = owner }
|
|
2951
|
+
for child in node["children"] as? [Any] ?? [] { registerSvgOwnership(child, owner: owner) }
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2692
2954
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
2693
2955
|
forgetHandlers(node)
|
|
2694
2956
|
if node.type == "Modal" {
|
|
@@ -2709,6 +2971,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2709
2971
|
flatListRows.removeValue(forKey: listKey)
|
|
2710
2972
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
2711
2973
|
}
|
|
2974
|
+
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
2712
2975
|
let id = ObjectIdentifier(node.view)
|
|
2713
2976
|
if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
|
|
2714
2977
|
handlers.removeValue(forKey: id)
|
|
@@ -3019,6 +3282,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3019
3282
|
updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
|
|
3020
3283
|
updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
|
|
3021
3284
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
3285
|
+
CraftNativeGradientLayer.apply(
|
|
3286
|
+
to: view,
|
|
3287
|
+
direction: style["gradientDirection"] as? String,
|
|
3288
|
+
colors: [color(style["gradientFrom"]), color(style["gradientVia"]), color(style["gradientTo"])]
|
|
3289
|
+
)
|
|
3022
3290
|
view.alpha = number(style["opacity"]) ?? 1
|
|
3023
3291
|
view.isHidden = style["display"] as? String == "none"
|
|
3024
3292
|
view.craftRequestedCornerRadius = number(style["borderRadius"])
|
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) {
|