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.
@@ -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.136";
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
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.136";
313
+ var version = "0.0.139";
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.136";
61
+ var version = "0.0.139";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -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
- signInWithApple: function() {
3588
- return this._invoke('signInWithApple');
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 { traits in
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 = UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.58, green: 0.77, blue: 0.99, alpha: 1) : UIColor(red: 0.11, green: 0.30, blue: 0.85, alpha: 1) }
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 = UIButton(type: .system)
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.136",
3
+ "version": "0.0.139",
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>",