craft-native 0.0.163 → 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.
@@ -341,8 +341,19 @@ private final class CraftNativeVideoView: UIView {
341
341
  detachPlayer()
342
342
  sourceURI = uri
343
343
  loops = false
344
- guard let url = URL(string: uri), !uri.isEmpty, url.scheme != nil else {
344
+ // The copy `craft.media.keep` put on the phone plays in place of
345
+ // the address, so a kept video plays with no signal.
346
+ let kept = CraftNativeMedia.local(uri)
347
+ guard let url = kept ?? URL(string: uri), !uri.isEmpty, kept != nil || url.scheme != nil else {
345
348
  playerController.player = nil
349
+ // A path with no scheme ("/videos/a.mp4") has nowhere to load
350
+ // from on a native screen. Say so rather than stay black.
351
+ if !uri.isEmpty {
352
+ DispatchQueue.main.async { [weak self] in
353
+ guard let self, self.sourceURI == uri else { return }
354
+ self.fire("onError", ["message": "The video address is not a full URL: \(uri)"])
355
+ }
356
+ }
346
357
  return
347
358
  }
348
359
  let next = AVPlayer(url: url)
@@ -539,6 +550,27 @@ private final class CraftNativeLayoutStyleBox {
539
550
 
540
551
  private var craftNativeLayoutStyleKey: UInt8 = 0
541
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
+ }
542
574
 
543
575
  extension UIView {
544
576
  /// The layout style this view's parent reads. It lives on the view, so an
@@ -560,11 +592,98 @@ extension UIView {
560
592
  }
561
593
  }
562
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
+
563
603
  func craftApplyCornerRadius() {
564
604
  let requested = craftRequestedCornerRadius ?? 0
565
605
  let limit = min(bounds.width, bounds.height) / 2
566
606
  let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
567
- 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)
568
687
  }
569
688
  }
570
689
 
@@ -659,6 +778,7 @@ private class CraftNativeFlowView: UIStackView {
659
778
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
660
779
  var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
661
780
  var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
781
+ var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
662
782
  var padding = UIEdgeInsets.zero {
663
783
  didSet {
664
784
  layoutMargins = padding
@@ -858,6 +978,29 @@ private class CraftNativeFlowView: UIStackView {
858
978
  return max(0, result)
859
979
  }
860
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
+
861
1004
  /// Children that take part in the flow: shown, not a modal overlay.
862
1005
  private var visibleChildren: [UIView] {
863
1006
  flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
@@ -1110,6 +1253,10 @@ private class CraftNativeFlowView: UIStackView {
1110
1253
  }
1111
1254
  if start < items.count { lines.append(start..<items.count) }
1112
1255
  }
1256
+ // Reconciliation reverses the child order for the requested main
1257
+ // axis. A wrapped flex container must keep its lines in cross-axis
1258
+ // order, so restore the groups without changing each line's order.
1259
+ if reverse && wrap && lines.count > 1 { lines.reverse() }
1113
1260
 
1114
1261
  var lineMains: [CGFloat] = []
1115
1262
  var lineCrosses: [CGFloat] = []
@@ -1240,31 +1387,53 @@ private class CraftNativeFlowView: UIStackView {
1240
1387
  }
1241
1388
  let lineCross = lineCrosses[lineIndex]
1242
1389
  let lineOffset = sharedCrossOffsets?[lineIndex] ?? crossOffset
1390
+ let crossReversed = wrapReverse && wrap
1391
+ let physicalLineOffset = crossReversed ? contentCross - lineOffset - lineCross : lineOffset
1243
1392
  let sharedLine = sharedLines[lineIndex]
1244
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
1245
1401
  var mainOffset = leading
1246
1402
  for (offset, index) in line.enumerated() {
1247
1403
  let item = items[index]
1248
- let alignment = item.align == "center" ? UInt32(1) : item.align == "flex-end" ? UInt32(2) : UInt32(0)
1249
- let crossPosition = CraftNativeCrossAxisBridge.position(
1250
- available: lineCross,
1251
- size: item.cross,
1252
- leading: item.crossLeading,
1253
- trailing: item.crossTrailing,
1254
- alignment: alignment
1255
- ) ?? {
1256
- switch alignment {
1257
- case 1: return item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
1258
- case 2: return lineCross - item.crossTrailing - item.cross
1259
- default: return item.crossLeading
1260
- }
1261
- }()
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
1262
1431
  let mainPosition = useSharedPositions
1263
1432
  ? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
1264
1433
  : mainOffset + item.mainLeading
1265
1434
  let frame = horizontal
1266
- ? CGRect(x: padding.left + mainPosition, y: padding.top + lineOffset + crossPosition, width: item.main, height: item.cross)
1267
- : 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)
1268
1437
  frames.append((item.view, frame))
1269
1438
  if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
1270
1439
  }
@@ -1922,7 +2091,9 @@ private final class CraftNativeSvgView: UIView {
1922
2091
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
1923
2092
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
1924
2093
  if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
1925
- if let opacity = props["opacity"] as? NSNumber ?? style["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
2094
+ // Computed style wins over the component prop, matching Android
2095
+ // and the browser's cascade when both provide opacity.
2096
+ if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
1926
2097
  if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
1927
2098
  if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
1928
2099
  if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
@@ -2431,9 +2602,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2431
2602
 
2432
2603
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
2433
2604
  super.traitCollectionDidChange(previousTraitCollection)
2434
- 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 }
2435
2608
  refreshTraitDefaults()
2436
- send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
2609
+ sendAppearance()
2437
2610
  }
2438
2611
 
2439
2612
  override func viewWillAppear(_ animated: Bool) {
@@ -2604,6 +2777,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2604
2777
  capabilities: \(capabilityJSON),
2605
2778
  initialAppState: "\(CraftNativeActions.currentAppState())",
2606
2779
  colorScheme: "\(colorScheme)",
2780
+ appearance: \(appearanceJSON()),
2607
2781
  appURL: \(appURL),
2608
2782
  appVersion: \(appVersion),
2609
2783
  appBuild: \(appBuild),
@@ -2684,6 +2858,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2684
2858
  return config.appearance == "dark" ? "dark" : "light"
2685
2859
  }
2686
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
+
2687
2873
  /// The screen's name as its script knows it: the route the host opened,
2688
2874
  /// else the one a route bundle selected for itself.
2689
2875
  private func screenName(in context: JSContext? = nil) -> String {
@@ -2709,7 +2895,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2709
2895
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2710
2896
  /// `craft.secureStorage.set` wrote.
2711
2897
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
2712
- /// rightButtons })`; a right button's tap is the event `navButton`.
2898
+ /// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
2899
+ /// tap is the event `navButton`.
2713
2900
  ///
2714
2901
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
2715
2902
  /// these: each namespace is a property whose setter adds them back to the
@@ -2953,6 +3140,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2953
3140
  private var navigationBarHidden: Bool?
2954
3141
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
2955
3142
  private var tabBarHidden = false
3143
+ /// Whether iOS 26's content-wide back-swipe may begin on this screen.
3144
+ /// Setting `contentSwipeBack: false` leaves the 32-point edge gesture.
3145
+ private(set) var contentSwipeBack = true
3146
+ /// Whether the shell's horizontal tab switch gesture may begin here.
3147
+ /// This is separate so a screen can keep navigation back while owning its
3148
+ /// own horizontal pager or chart gesture.
3149
+ private(set) var tabSwipeEnabled = true
2956
3150
  private var prefersLargeTitle: Bool?
2957
3151
 
2958
3152
  /// Whether this screen asked for a bar of its own through `setOptions`: a
@@ -2988,6 +3182,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2988
3182
  CraftChrome.shared.setHiddenByScreen(hidden)
2989
3183
  }
2990
3184
  }
3185
+ if let contentSwipeBack = options["contentSwipeBack"] as? Bool {
3186
+ self.contentSwipeBack = contentSwipeBack
3187
+ }
3188
+ if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
3189
+ self.tabSwipeEnabled = tabSwipeEnabled
3190
+ }
2991
3191
  if let hidden = options["hidden"] as? Bool {
2992
3192
  navigationBarHidden = hidden
2993
3193
  if navigationController?.topViewController === self {
@@ -3172,6 +3372,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3172
3372
  observe(UIApplication.didBecomeActiveNotification, state: "active")
3173
3373
  observe(UIApplication.willResignActiveNotification, state: "inactive")
3174
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
+ }
3175
3385
  // The app script has no screen for the keyboard to cover.
3176
3386
  if !isAppScript {
3177
3387
  lifecycleObservers.append(NotificationCenter.default.addObserver(
@@ -4643,7 +4853,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4643
4853
  let direction = style["flexDirection"] as? String
4644
4854
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
4645
4855
  stack.reverse = direction?.hasSuffix("-reverse") == true
4646
- 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"
4647
4859
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
4648
4860
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
4649
4861
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
@@ -4679,14 +4891,37 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4679
4891
  )
4680
4892
  view.alpha = number(style["opacity"]) ?? 1
4681
4893
  view.isHidden = style["display"] as? String == "none"
4682
- view.craftRequestedCornerRadius = number(style["borderRadius"])
4683
- view.layer.borderWidth = number(style["borderWidth"]) ?? 0
4684
- 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)
4685
4913
  let elevation = max(0, number(style["elevation"]) ?? 0)
4686
- view.layer.shadowColor = UIColor.black.cgColor
4687
- view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
4688
- view.layer.shadowRadius = elevation * 0.5
4689
- 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
+ )
4690
4925
  // A label paints its background inside its own bounds, so a rounded
4691
4926
  // chip of text clips to its corners.
4692
4927
  view.clipsToBounds = style["overflow"] as? String == "hidden"
@@ -4709,6 +4944,49 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4709
4944
  )
4710
4945
  }
4711
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
+
4712
4990
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
4713
4991
  let transformed = transformedText(text, style: style)
4714
4992
  label.textColor = color(style["color"]) ?? .label
@@ -4716,14 +4994,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4716
4994
  label.textAlignment = textAlignment(style["textAlign"])
4717
4995
  var attributes: [NSAttributedString.Key: Any] = [:]
4718
4996
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
4719
- switch style["textDecorationLine"] as? String {
4720
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4721
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4722
- case "underline line-through":
4723
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4724
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4725
- default: break
4726
- }
4997
+ addTextDecorationAttributes(style, to: &attributes)
4998
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4727
4999
  if let lineHeight = number(style["lineHeight"]) {
4728
5000
  let paragraph = NSMutableParagraphStyle()
4729
5001
  paragraph.minimumLineHeight = lineHeight
@@ -4768,14 +5040,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4768
5040
  paragraph.maximumLineHeight = lineHeight
4769
5041
  attributes[.paragraphStyle] = paragraph
4770
5042
  }
4771
- switch style["textDecorationLine"] as? String {
4772
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4773
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4774
- case "underline line-through":
4775
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4776
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4777
- default: break
4778
- }
5043
+ addTextDecorationAttributes(style, to: &attributes)
5044
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4779
5045
  if forceUnderline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
4780
5046
  return attributes
4781
5047
  }
@@ -4797,17 +5063,46 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4797
5063
  paragraph.alignment = alignment
4798
5064
  attributes[.paragraphStyle] = paragraph
4799
5065
  }
4800
- switch style["textDecorationLine"] as? String {
4801
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4802
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4803
- case "underline line-through":
4804
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4805
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4806
- default: break
4807
- }
5066
+ addTextDecorationAttributes(style, to: &attributes)
5067
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4808
5068
  return attributes
4809
5069
  }
4810
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
+
4811
5106
  private func transformedText(_ text: String, style: [String: Any]) -> String {
4812
5107
  switch style["textTransform"] as? String {
4813
5108
  case "uppercase": return text.uppercased()
@@ -5206,11 +5501,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5206
5501
  if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
5207
5502
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
5208
5503
  if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
5209
- let parts = text.split(separator: ":").compactMap { Double($0) }
5210
- guard parts.count == 2 || parts.count == 3 else { return nil }
5211
- let hours = parts[0]
5212
- let minutes = parts[1]
5213
- 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
5214
5509
  guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
5215
5510
  return min(maximum, hours * 3600 + minutes * 60 + seconds)
5216
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";
@@ -1385,6 +1391,12 @@ var health = {
1385
1391
  throw new Error("Health data is unavailable");
1386
1392
  return craft.health.getData(type, options);
1387
1393
  },
1394
+ async saveData(type, value, options = {}) {
1395
+ const craft = getCraftRoot();
1396
+ if (!craft?.health?.saveData)
1397
+ throw new Error("Saving health data is unavailable");
1398
+ return craft.health.saveData(type, value, options);
1399
+ },
1388
1400
  async saveWorkout(workout) {
1389
1401
  const craft = getCraftRoot();
1390
1402
  if (!craft?.health?.saveWorkout)
@@ -1426,6 +1438,29 @@ var health = {
1426
1438
  if (!craft?.health?.getMindfulSessions)
1427
1439
  throw new Error("Reading mindful sessions is unavailable");
1428
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);
1429
1464
  }
1430
1465
  };
1431
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.163",
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>",