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.
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/MainActivityNative.kt.template +516 -119
- package/dist/api/mobile.d.ts +34 -2
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +1 -0
- package/dist/ios/src/index.js +138 -88
- package/dist/ios/src/source-audit.d.ts +8 -0
- package/dist/ios/templates/CraftApp.swift +94 -2
- package/dist/ios/templates/CraftHealth.swift +261 -40
- package/dist/ios/templates/CraftHeartRate.swift +34 -4
- package/dist/ios/templates/CraftHybrid.swift +174 -2
- package/dist/ios/templates/CraftNativeActions.swift +111 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +354 -59
- package/dist/mobile.js +35 -0
- package/package.json +1 -1
|
@@ -341,8 +341,19 @@ private final class CraftNativeVideoView: UIView {
|
|
|
341
341
|
detachPlayer()
|
|
342
342
|
sourceURI = uri
|
|
343
343
|
loops = false
|
|
344
|
-
|
|
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
|
|
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
|
|
1249
|
-
let
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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 +
|
|
1267
|
-
: CGRect(x: padding.left +
|
|
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
|
-
|
|
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
|
-
|
|
2605
|
+
let colorChanged = previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
|
|
2606
|
+
let textSizeChanged = previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory
|
|
2607
|
+
guard colorChanged || textSizeChanged else { return }
|
|
2435
2608
|
refreshTraitDefaults()
|
|
2436
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
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
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
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
|
-
|
|
4720
|
-
|
|
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
|
-
|
|
4772
|
-
|
|
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
|
-
|
|
4801
|
-
|
|
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: ":")
|
|
5210
|
-
guard parts.count == 2 || parts.count == 3
|
|
5211
|
-
|
|
5212
|
-
|
|
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) {
|