craft-native 0.0.164 → 0.0.166

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.
@@ -550,6 +550,27 @@ private final class CraftNativeLayoutStyleBox {
550
550
 
551
551
  private var craftNativeLayoutStyleKey: UInt8 = 0
552
552
  private var craftNativeCornerRadiusKey: UInt8 = 0
553
+ private var craftNativeCornerRadiiKey: UInt8 = 0
554
+ private var craftNativeBorderLayerKey: UInt8 = 0
555
+ private var craftNativeSideBorderLayersKey: UInt8 = 0
556
+
557
+ private func craftRoundedPath(_ rect: CGRect, _ rawRadii: [CGFloat]) -> CGPath {
558
+ let limit = min(rect.width, rect.height) / 2
559
+ let radii = (0..<4).map { min(max(0, rawRadii.count > $0 ? rawRadii[$0] : 0), limit) }
560
+ let path = CGMutablePath()
561
+ let minX = rect.minX, maxX = rect.maxX, minY = rect.minY, maxY = rect.maxY
562
+ path.move(to: CGPoint(x: minX + radii[0], y: minY))
563
+ path.addLine(to: CGPoint(x: maxX - radii[1], y: minY))
564
+ path.addArc(center: CGPoint(x: maxX - radii[1], y: minY + radii[1]), radius: radii[1], startAngle: -.pi / 2, endAngle: 0, clockwise: false)
565
+ path.addLine(to: CGPoint(x: maxX, y: maxY - radii[2]))
566
+ path.addArc(center: CGPoint(x: maxX - radii[2], y: maxY - radii[2]), radius: radii[2], startAngle: 0, endAngle: .pi / 2, clockwise: false)
567
+ path.addLine(to: CGPoint(x: minX + radii[3], y: maxY))
568
+ path.addArc(center: CGPoint(x: minX + radii[3], y: maxY - radii[3]), radius: radii[3], startAngle: .pi / 2, endAngle: .pi, clockwise: false)
569
+ path.addLine(to: CGPoint(x: minX, y: minY + radii[0]))
570
+ path.addArc(center: CGPoint(x: minX + radii[0], y: minY + radii[0]), radius: radii[0], startAngle: .pi, endAngle: .pi * 1.5, clockwise: false)
571
+ path.closeSubpath()
572
+ return path
573
+ }
553
574
 
554
575
  extension UIView {
555
576
  /// The layout style this view's parent reads. It lives on the view, so an
@@ -571,11 +592,98 @@ extension UIView {
571
592
  }
572
593
  }
573
594
 
595
+ var craftRequestedCornerRadii: [CGFloat]? {
596
+ get { (objc_getAssociatedObject(self, &craftNativeCornerRadiiKey) as? [NSNumber])?.map { CGFloat(truncating: $0) } }
597
+ set {
598
+ objc_setAssociatedObject(self, &craftNativeCornerRadiiKey, newValue?.map { NSNumber(value: Double($0)) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
599
+ craftApplyCornerRadius()
600
+ }
601
+ }
602
+
574
603
  func craftApplyCornerRadius() {
575
604
  let requested = craftRequestedCornerRadius ?? 0
576
605
  let limit = min(bounds.width, bounds.height) / 2
577
606
  let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
578
- if layer.cornerRadius != radius { layer.cornerRadius = radius }
607
+ if let radii = craftRequestedCornerRadii {
608
+ let mask = (layer.mask as? CAShapeLayer) ?? CAShapeLayer()
609
+ mask.frame = bounds
610
+ mask.path = craftRoundedPath(bounds, radii)
611
+ layer.mask = mask
612
+ layer.cornerRadius = 0
613
+ } else {
614
+ layer.mask = nil
615
+ if layer.cornerRadius != radius { layer.cornerRadius = radius }
616
+ }
617
+ if let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer {
618
+ border.frame = bounds
619
+ let inset = border.lineWidth / 2
620
+ border.path = UIBezierPath(
621
+ roundedRect: bounds.insetBy(dx: inset, dy: inset),
622
+ cornerRadius: max(0, radius - inset)
623
+ ).cgPath
624
+ }
625
+ if let sides = objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer], sides.count == 4 {
626
+ let top = sides[0].frame.height
627
+ let right = sides[1].frame.width
628
+ let bottom = sides[2].frame.height
629
+ let left = sides[3].frame.width
630
+ sides[0].frame = CGRect(x: 0, y: 0, width: bounds.width, height: top)
631
+ sides[1].frame = CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height)
632
+ sides[2].frame = CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom)
633
+ sides[3].frame = CGRect(x: 0, y: 0, width: left, height: bounds.height)
634
+ }
635
+ }
636
+
637
+ fileprivate func craftApplyBorderStyle(_ style: String?, width: CGFloat, color: UIColor) {
638
+ guard width > 0, style == "dashed" || style == "dotted" else {
639
+ (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
640
+ objc_setAssociatedObject(self, &craftNativeBorderLayerKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
641
+ layer.borderWidth = width
642
+ layer.borderColor = color.cgColor
643
+ return
644
+ }
645
+ layer.borderWidth = 0
646
+ let border = (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer) ?? CAShapeLayer()
647
+ border.strokeColor = color.cgColor
648
+ border.fillColor = nil
649
+ border.lineWidth = width
650
+ border.lineDashPattern = style == "dotted"
651
+ ? [NSNumber(value: Double(width)), NSNumber(value: Double(width * 1.5))]
652
+ : [NSNumber(value: Double(width * 3)), NSNumber(value: Double(width * 2))]
653
+ border.contentsScale = window?.screen.scale ?? traitCollection.displayScale
654
+ if border.superlayer !== layer { layer.addSublayer(border) }
655
+ objc_setAssociatedObject(self, &craftNativeBorderLayerKey, border, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
656
+ craftApplyCornerRadius()
657
+ }
658
+
659
+ fileprivate func craftApplySideBorders(_ hasSideBorders: Bool, style: String?, widths: [CGFloat], colors: [UIColor]) {
660
+ guard hasSideBorders else {
661
+ (objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer])?.forEach { $0.removeFromSuperlayer() }
662
+ objc_setAssociatedObject(self, &craftNativeSideBorderLayersKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
663
+ craftApplyBorderStyle(style, width: widths.first ?? 0, color: colors.first ?? .clear)
664
+ return
665
+ }
666
+ (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
667
+ objc_setAssociatedObject(self, &craftNativeBorderLayerKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
668
+ layer.borderWidth = 0
669
+ let top = max(0, widths.count > 0 ? widths[0] : 0)
670
+ let right = max(0, widths.count > 1 ? widths[1] : 0)
671
+ let bottom = max(0, widths.count > 2 ? widths[2] : 0)
672
+ let left = max(0, widths.count > 3 ? widths[3] : 0)
673
+ let frames = [
674
+ CGRect(x: 0, y: 0, width: bounds.width, height: top),
675
+ CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height),
676
+ CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom),
677
+ CGRect(x: 0, y: 0, width: left, height: bounds.height),
678
+ ]
679
+ let layers = frames.enumerated().map { index, frame in
680
+ let side = CALayer()
681
+ side.frame = frame
682
+ side.backgroundColor = (colors.count > index ? colors[index] : .clear).cgColor
683
+ layer.addSublayer(side)
684
+ return side
685
+ }
686
+ objc_setAssociatedObject(self, &craftNativeSideBorderLayersKey, layers, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
579
687
  }
580
688
  }
581
689
 
@@ -670,6 +778,7 @@ private class CraftNativeFlowView: UIStackView {
670
778
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
671
779
  var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
672
780
  var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
781
+ var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
673
782
  var padding = UIEdgeInsets.zero {
674
783
  didSet {
675
784
  layoutMargins = padding
@@ -869,6 +978,29 @@ private class CraftNativeFlowView: UIStackView {
869
978
  return max(0, result)
870
979
  }
871
980
 
981
+ /// The first baseline is the cross-axis anchor for a horizontal baseline
982
+ /// flex line. Views without a baseline participate at their bottom edge,
983
+ /// matching the CSS fallback for replaced and non-text elements.
984
+ private static func firstBaseline(_ view: UIView, cross: CGFloat) -> CGFloat {
985
+ let baseline: CGFloat?
986
+ if let label = view as? CraftNativeLabel {
987
+ // CraftNativeLabel draws inside its inset rect, so include the
988
+ // authored top padding before the font's own baseline (UIKit's
989
+ // labels expose no measured first-baseline offset).
990
+ baseline = label.insets.top + label.font.ascender
991
+ } else if let label = view as? UILabel {
992
+ baseline = label.font.ascender
993
+ } else if let field = view as? UITextField {
994
+ baseline = field.font?.ascender
995
+ } else if let textView = view as? UITextView {
996
+ baseline = textView.font?.ascender
997
+ } else {
998
+ baseline = nil
999
+ }
1000
+ guard let baseline, baseline.isFinite else { return cross }
1001
+ return min(max(0, baseline), cross)
1002
+ }
1003
+
872
1004
  /// Children that take part in the flow: shown, not a modal overlay.
873
1005
  private var visibleChildren: [UIView] {
874
1006
  flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
@@ -1255,31 +1387,53 @@ private class CraftNativeFlowView: UIStackView {
1255
1387
  }
1256
1388
  let lineCross = lineCrosses[lineIndex]
1257
1389
  let lineOffset = sharedCrossOffsets?[lineIndex] ?? crossOffset
1390
+ let crossReversed = wrapReverse && wrap
1391
+ let physicalLineOffset = crossReversed ? contentCross - lineOffset - lineCross : lineOffset
1258
1392
  let sharedLine = sharedLines[lineIndex]
1259
1393
  let useSharedPositions = !reverse && sharedLine != nil
1394
+ let baseline = horizontal
1395
+ ? line.compactMap { index -> CGFloat? in
1396
+ let item = items[index]
1397
+ guard item.align == "baseline" else { return nil }
1398
+ return item.crossLeading + Self.firstBaseline(item.view, cross: item.cross)
1399
+ }.max()
1400
+ : nil
1260
1401
  var mainOffset = leading
1261
1402
  for (offset, index) in line.enumerated() {
1262
1403
  let item = items[index]
1263
- let alignment = item.align == "center" ? UInt32(1) : item.align == "flex-end" ? UInt32(2) : UInt32(0)
1264
- let crossPosition = CraftNativeCrossAxisBridge.position(
1265
- available: lineCross,
1266
- size: item.cross,
1267
- leading: item.crossLeading,
1268
- trailing: item.crossTrailing,
1269
- alignment: alignment
1270
- ) ?? {
1271
- switch alignment {
1272
- case 1: return item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
1273
- case 2: return lineCross - item.crossTrailing - item.cross
1274
- default: return item.crossLeading
1275
- }
1276
- }()
1404
+ let crossPosition: CGFloat
1405
+ if item.align == "baseline", let baseline {
1406
+ let childBaseline = Self.firstBaseline(item.view, cross: item.cross)
1407
+ crossPosition = CraftNativeCrossAxisBridge.position(
1408
+ available: baseline,
1409
+ size: childBaseline,
1410
+ leading: item.crossLeading,
1411
+ trailing: item.crossTrailing,
1412
+ alignment: 3
1413
+ ) ?? max(item.crossLeading, baseline - childBaseline)
1414
+ } else {
1415
+ let alignment = item.align == "center" ? UInt32(1) : item.align == "flex-end" ? UInt32(2) : UInt32(0)
1416
+ crossPosition = CraftNativeCrossAxisBridge.position(
1417
+ available: lineCross,
1418
+ size: item.cross,
1419
+ leading: item.crossLeading,
1420
+ trailing: item.crossTrailing,
1421
+ alignment: alignment
1422
+ ) ?? {
1423
+ switch alignment {
1424
+ case 1: return item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
1425
+ case 2: return lineCross - item.crossTrailing - item.cross
1426
+ default: return item.crossLeading
1427
+ }
1428
+ }()
1429
+ }
1430
+ let physicalCrossPosition = crossReversed ? lineCross - crossPosition - item.cross : crossPosition
1277
1431
  let mainPosition = useSharedPositions
1278
1432
  ? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
1279
1433
  : mainOffset + item.mainLeading
1280
1434
  let frame = horizontal
1281
- ? CGRect(x: padding.left + mainPosition, y: padding.top + lineOffset + crossPosition, width: item.main, height: item.cross)
1282
- : CGRect(x: padding.left + lineOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
1435
+ ? CGRect(x: padding.left + mainPosition, y: padding.top + physicalLineOffset + physicalCrossPosition, width: item.main, height: item.cross)
1436
+ : CGRect(x: padding.left + physicalLineOffset + physicalCrossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
1283
1437
  frames.append((item.view, frame))
1284
1438
  if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
1285
1439
  }
@@ -2448,9 +2602,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2448
2602
 
2449
2603
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
2450
2604
  super.traitCollectionDidChange(previousTraitCollection)
2451
- guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
2605
+ let colorChanged = previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
2606
+ let textSizeChanged = previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory
2607
+ guard colorChanged || textSizeChanged else { return }
2452
2608
  refreshTraitDefaults()
2453
- send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
2609
+ sendAppearance()
2454
2610
  }
2455
2611
 
2456
2612
  override func viewWillAppear(_ animated: Bool) {
@@ -2621,6 +2777,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2621
2777
  capabilities: \(capabilityJSON),
2622
2778
  initialAppState: "\(CraftNativeActions.currentAppState())",
2623
2779
  colorScheme: "\(colorScheme)",
2780
+ appearance: \(appearanceJSON()),
2624
2781
  appURL: \(appURL),
2625
2782
  appVersion: \(appVersion),
2626
2783
  appBuild: \(appBuild),
@@ -2701,6 +2858,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2701
2858
  return config.appearance == "dark" ? "dark" : "light"
2702
2859
  }
2703
2860
 
2861
+ private func sendAppearance() {
2862
+ let style: UIUserInterfaceStyle = colorScheme == "dark" ? .dark : .light
2863
+ send(type: "APPEARANCE", payload: CraftPageAppearance.snapshot(style: style))
2864
+ }
2865
+
2866
+ private func appearanceJSON() -> String {
2867
+ let style: UIUserInterfaceStyle = colorScheme == "dark" ? .dark : .light
2868
+ guard let data = try? JSONSerialization.data(withJSONObject: CraftPageAppearance.snapshot(style: style)),
2869
+ let json = String(data: data, encoding: .utf8) else { return "{}" }
2870
+ return json
2871
+ }
2872
+
2704
2873
  /// The screen's name as its script knows it: the route the host opened,
2705
2874
  /// else the one a route bundle selected for itself.
2706
2875
  private func screenName(in context: JSContext? = nil) -> String {
@@ -3203,6 +3372,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3203
3372
  observe(UIApplication.didBecomeActiveNotification, state: "active")
3204
3373
  observe(UIApplication.willResignActiveNotification, state: "inactive")
3205
3374
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
3375
+ for name in [UIContentSizeCategory.didChangeNotification, UIAccessibility.reduceMotionStatusDidChangeNotification, UIAccessibility.reduceTransparencyStatusDidChangeNotification, .craftColorSchemeChanged] {
3376
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
3377
+ forName: name,
3378
+ object: nil,
3379
+ queue: .main
3380
+ ) { [weak self] _ in
3381
+ self?.refreshTraitDefaults()
3382
+ self?.sendAppearance()
3383
+ })
3384
+ }
3206
3385
  // The app script has no screen for the keyboard to cover.
3207
3386
  if !isAppScript {
3208
3387
  lifecycleObservers.append(NotificationCenter.default.addObserver(
@@ -4674,7 +4853,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4674
4853
  let direction = style["flexDirection"] as? String
4675
4854
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
4676
4855
  stack.reverse = direction?.hasSuffix("-reverse") == true
4677
- stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
4856
+ let flexWrap = style["flexWrap"] as? String
4857
+ stack.wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || style["flexWrap"] as? Bool == true
4858
+ stack.wrapReverse = flexWrap == "wrap-reverse"
4678
4859
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
4679
4860
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
4680
4861
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
@@ -4710,14 +4891,37 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4710
4891
  )
4711
4892
  view.alpha = number(style["opacity"]) ?? 1
4712
4893
  view.isHidden = style["display"] as? String == "none"
4713
- view.craftRequestedCornerRadius = number(style["borderRadius"])
4714
- view.layer.borderWidth = number(style["borderWidth"]) ?? 0
4715
- view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
4894
+ let borderRadius = number(style["borderRadius"]) ?? 0
4895
+ let cornerRadiusKeys = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]
4896
+ let hasCornerRadii = cornerRadiusKeys.contains { style[$0] != nil }
4897
+ view.craftRequestedCornerRadius = borderRadius
4898
+ view.craftRequestedCornerRadii = hasCornerRadii
4899
+ ? cornerRadiusKeys.map { number(style[$0]) ?? borderRadius }
4900
+ : nil
4901
+ let borderWidth = number(style["borderWidth"]) ?? 0
4902
+ let borderColor = color(style["borderColor"]) ?? .clear
4903
+ let sideWidthKeys = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]
4904
+ let sideColorKeys = ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
4905
+ view.craftApplySideBorders(
4906
+ (sideWidthKeys + sideColorKeys).contains { style[$0] != nil },
4907
+ style: style["borderStyle"] as? String,
4908
+ widths: sideWidthKeys.map { number(style[$0]) ?? borderWidth },
4909
+ colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
4910
+ )
4911
+ view.layer.zPosition = number(style["zIndex"]) ?? 0
4912
+ applyTransform(style["transform"], to: view)
4716
4913
  let elevation = max(0, number(style["elevation"]) ?? 0)
4717
- view.layer.shadowColor = UIColor.black.cgColor
4718
- view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
4719
- view.layer.shadowRadius = elevation * 0.5
4720
- view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
4914
+ let shadowColor = color(style["shadowColor"]) ?? .black
4915
+ let shadowOpacity = min(1, max(0, number(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
4916
+ let shadowRadius = max(0, number(style["shadowRadius"]) ?? elevation * 0.5)
4917
+ let shadowOffset = style["shadowOffset"] as? [String: Any]
4918
+ view.layer.shadowColor = shadowColor.cgColor
4919
+ view.layer.shadowOpacity = Float(shadowOpacity)
4920
+ view.layer.shadowRadius = shadowRadius
4921
+ view.layer.shadowOffset = CGSize(
4922
+ width: number(shadowOffset?["width"]) ?? 0,
4923
+ height: number(shadowOffset?["height"]) ?? (elevation * 0.25)
4924
+ )
4721
4925
  // A label paints its background inside its own bounds, so a rounded
4722
4926
  // chip of text clips to its corners.
4723
4927
  view.clipsToBounds = style["overflow"] as? String == "hidden"
@@ -4740,6 +4944,49 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4740
4944
  )
4741
4945
  }
4742
4946
 
4947
+ private func applyTransform(_ raw: Any?, to view: UIView) {
4948
+ view.transform = .identity
4949
+ view.layer.transform = CATransform3DIdentity
4950
+ guard let transforms = raw as? [[String: Any]] else { return }
4951
+ var affine = CGAffineTransform.identity
4952
+ var threeDimensional = CATransform3DIdentity
4953
+ var hasThreeDimensionalRotation = false
4954
+ for transform in transforms {
4955
+ if let value = number(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
4956
+ if let value = number(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
4957
+ if let value = number(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
4958
+ if let value = number(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
4959
+ if let value = number(transform["scaleY"]) { affine = affine.scaledBy(x: 1, y: value) }
4960
+ if let value = transformAngle(transform["rotate"] ?? transform["rotateZ"]) { affine = affine.rotated(by: value) }
4961
+ if let value = transformAngle(transform["skewX"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(value), d: 1, tx: 0, ty: 0)) }
4962
+ if let value = transformAngle(transform["skewY"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: tan(value), c: 0, d: 1, tx: 0, ty: 0)) }
4963
+ if let value = transformAngle(transform["rotateX"]) {
4964
+ threeDimensional = CATransform3DRotate(threeDimensional, value, 1, 0, 0)
4965
+ hasThreeDimensionalRotation = true
4966
+ }
4967
+ if let value = transformAngle(transform["rotateY"]) {
4968
+ threeDimensional = CATransform3DRotate(threeDimensional, value, 0, 1, 0)
4969
+ hasThreeDimensionalRotation = true
4970
+ }
4971
+ }
4972
+ if hasThreeDimensionalRotation {
4973
+ view.layer.transform = CATransform3DConcat(CATransform3DMakeAffineTransform(affine), threeDimensional)
4974
+ } else {
4975
+ view.transform = affine
4976
+ }
4977
+ }
4978
+
4979
+ private func transformAngle(_ raw: Any?) -> CGFloat? {
4980
+ if let value = number(raw) { return value }
4981
+ guard let text = raw as? String else { return nil }
4982
+ if text.hasSuffix("rad") { return CGFloat(Double(text.dropLast(3)) ?? 0) }
4983
+ if text.hasSuffix("deg") { return CGFloat((Double(text.dropLast(3)) ?? 0) * .pi / 180) }
4984
+ // Keep the conversion explicit: newer Swift toolchains see both the
4985
+ // CGFloat(Double) and CGFloat(Float) initializers through map and
4986
+ // reject the overloaded constructor reference.
4987
+ return Double(text).map { CGFloat($0) }
4988
+ }
4989
+
4743
4990
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
4744
4991
  let transformed = transformedText(text, style: style)
4745
4992
  label.textColor = color(style["color"]) ?? .label
@@ -4747,14 +4994,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4747
4994
  label.textAlignment = textAlignment(style["textAlign"])
4748
4995
  var attributes: [NSAttributedString.Key: Any] = [:]
4749
4996
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
4750
- switch style["textDecorationLine"] as? String {
4751
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4752
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4753
- case "underline line-through":
4754
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4755
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4756
- default: break
4757
- }
4997
+ addTextDecorationAttributes(style, to: &attributes)
4998
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4758
4999
  if let lineHeight = number(style["lineHeight"]) {
4759
5000
  let paragraph = NSMutableParagraphStyle()
4760
5001
  paragraph.minimumLineHeight = lineHeight
@@ -4799,14 +5040,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4799
5040
  paragraph.maximumLineHeight = lineHeight
4800
5041
  attributes[.paragraphStyle] = paragraph
4801
5042
  }
4802
- switch style["textDecorationLine"] as? String {
4803
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4804
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4805
- case "underline line-through":
4806
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4807
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4808
- default: break
4809
- }
5043
+ addTextDecorationAttributes(style, to: &attributes)
5044
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4810
5045
  if forceUnderline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
4811
5046
  return attributes
4812
5047
  }
@@ -4828,17 +5063,46 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4828
5063
  paragraph.alignment = alignment
4829
5064
  attributes[.paragraphStyle] = paragraph
4830
5065
  }
4831
- switch style["textDecorationLine"] as? String {
4832
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4833
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4834
- case "underline line-through":
4835
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4836
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4837
- default: break
4838
- }
5066
+ addTextDecorationAttributes(style, to: &attributes)
5067
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4839
5068
  return attributes
4840
5069
  }
4841
5070
 
5071
+ private func textShadow(_ style: [String: Any]) -> NSShadow? {
5072
+ guard let shadowColor = color(style["textShadowColor"]) else { return nil }
5073
+ let offset = style["textShadowOffset"] as? [String: Any]
5074
+ let shadow = NSShadow()
5075
+ shadow.shadowColor = shadowColor
5076
+ shadow.shadowOffset = CGSize(
5077
+ width: number(offset?["width"]) ?? 0,
5078
+ height: number(offset?["height"]) ?? 0
5079
+ )
5080
+ shadow.shadowBlurRadius = max(0, number(style["textShadowRadius"]) ?? 0)
5081
+ return shadow
5082
+ }
5083
+
5084
+ private func addTextDecorationAttributes(_ style: [String: Any], to attributes: inout [NSAttributedString.Key: Any]) {
5085
+ let line = style["textDecorationLine"] as? String
5086
+ let decorationStyle: NSUnderlineStyle = {
5087
+ switch style["textDecorationStyle"] as? String {
5088
+ case "double": return .double
5089
+ case "dotted": return [.single, .patternDot]
5090
+ case "dashed": return [.single, .patternDash]
5091
+ default: return .single
5092
+ }
5093
+ }()
5094
+ let rawStyle = decorationStyle.rawValue
5095
+ let decorationColor = color(style["textDecorationColor"])
5096
+ if line == "underline" || line == "underline line-through" {
5097
+ attributes[.underlineStyle] = rawStyle
5098
+ if let decorationColor { attributes[.underlineColor] = decorationColor }
5099
+ }
5100
+ if line == "line-through" || line == "underline line-through" {
5101
+ attributes[.strikethroughStyle] = rawStyle
5102
+ if let decorationColor { attributes[.strikethroughColor] = decorationColor }
5103
+ }
5104
+ }
5105
+
4842
5106
  private func transformedText(_ text: String, style: [String: Any]) -> String {
4843
5107
  switch style["textTransform"] as? String {
4844
5108
  case "uppercase": return text.uppercased()
@@ -5237,11 +5501,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5237
5501
  if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
5238
5502
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
5239
5503
  if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
5240
- let parts = text.split(separator: ":").compactMap { Double($0) }
5241
- guard parts.count == 2 || parts.count == 3 else { return nil }
5242
- let hours = parts[0]
5243
- let minutes = parts[1]
5244
- let seconds = parts.count == 3 ? parts[2] : 0
5504
+ let parts = text.split(separator: ":")
5505
+ guard parts.count == 2 || parts.count == 3,
5506
+ let hours = Double(parts[0]),
5507
+ let minutes = Double(parts[1]) else { return nil }
5508
+ let seconds = parts.count == 3 ? (Double(parts[2]) ?? -1) : 0
5245
5509
  guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
5246
5510
  return min(maximum, hours * 3600 + minutes * 60 + seconds)
5247
5511
  }
package/dist/mobile.js CHANGED
@@ -1369,6 +1369,12 @@ var pushNotifications = {
1369
1369
  return onCraftEvent("craftNotificationReceived", (detail) => callback(detail));
1370
1370
  }
1371
1371
  };
1372
+ function healthHeartRateSamples(detail) {
1373
+ const samples = detail?.samples;
1374
+ if (!Array.isArray(samples))
1375
+ return [];
1376
+ return samples.filter((sample) => typeof sample?.bpm === "number" && Number.isFinite(sample.bpm) && typeof sample.startDate === "number" && typeof sample.endDate === "number");
1377
+ }
1372
1378
  var health = {
1373
1379
  supports(method) {
1374
1380
  return typeof getCraftRoot()?.health?.[method] === "function";
@@ -1432,6 +1438,29 @@ var health = {
1432
1438
  if (!craft?.health?.getMindfulSessions)
1433
1439
  throw new Error("Reading mindful sessions is unavailable");
1434
1440
  return craft.health.getMindfulSessions(options);
1441
+ },
1442
+ async startHeartRateUpdates(options = {}) {
1443
+ const craft = getCraftRoot();
1444
+ if (!craft?.health?.startHeartRateUpdates)
1445
+ throw new Error("Heart-rate updates are unavailable");
1446
+ return Boolean(await craft.health.startHeartRateUpdates(options));
1447
+ },
1448
+ async stopHeartRateUpdates() {
1449
+ const craft = getCraftRoot();
1450
+ if (!craft?.health?.stopHeartRateUpdates)
1451
+ throw new Error("Heart-rate updates are unavailable");
1452
+ return Boolean(await craft.health.stopHeartRateUpdates());
1453
+ },
1454
+ onHeartRate(callback) {
1455
+ if (typeof window === "undefined" || typeof getCraftRoot()?.health?.startHeartRateUpdates !== "function")
1456
+ return () => {};
1457
+ const listener = (event) => {
1458
+ const samples = healthHeartRateSamples(event.detail);
1459
+ if (samples.length)
1460
+ callback(samples);
1461
+ };
1462
+ window.addEventListener("craftHealthHeartRate", listener);
1463
+ return () => window.removeEventListener("craftHealthHeartRate", listener);
1435
1464
  }
1436
1465
  };
1437
1466
  async function updateLiveActivity(idOrState, state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.164",
3
+ "version": "0.0.166",
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>",