craft-native 0.0.175 → 0.0.176

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.
Files changed (32) hide show
  1. package/dist/android/src/index.d.ts +19 -0
  2. package/dist/android/src/index.js +19 -2
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +51 -0
  5. package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
  6. package/dist/android/templates/CraftNative.kt.template +17 -0
  7. package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
  8. package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
  9. package/dist/android/templates/CraftNativeServices.kt.template +31 -1
  10. package/dist/android/templates/MainActivityNative.kt.template +986 -304
  11. package/dist/android/templates/build.gradle.kts.app.template +4 -2
  12. package/dist/api/mobile.d.ts +31 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +203 -2
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/src/index.d.ts +19 -0
  20. package/dist/ios/src/index.js +7 -1
  21. package/dist/ios/templates/CraftApp.swift +51 -1
  22. package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
  23. package/dist/ios/templates/CraftHybrid.swift +10 -0
  24. package/dist/ios/templates/CraftNativeActions.swift +49 -1
  25. package/dist/ios/templates/CraftNativeFlatList.swift +13 -21
  26. package/dist/ios/templates/CraftNativeMutation.swift +1 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +393 -55
  28. package/dist/ios/templates/project.yml.template +1 -0
  29. package/dist/macos/templates/CraftNativeMutation.swift +1 -1
  30. package/dist/macos/templates/native_layout.zig +389 -17
  31. package/dist/mobile.js +66 -0
  32. package/package.json +1 -1
@@ -12,6 +12,15 @@ import UIKit
12
12
 
13
13
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
14
14
 
15
+ /// A finite number from a prop, for the views outside the screen controller,
16
+ /// which has its own: the keyboard-avoiding view and SVG text read theirs
17
+ /// with it, and without it the template did not compile.
18
+ private func number(_ value: Any?) -> CGFloat? {
19
+ guard let number = value as? NSNumber else { return nil }
20
+ let value = CGFloat(truncating: number)
21
+ return value.isFinite ? value : nil
22
+ }
23
+
15
24
  private func craftNativeGridColumnCount(_ value: Any?) -> Int {
16
25
  if let number = value as? NSNumber { return max(1, number.intValue) }
17
26
  guard let text = value as? String else { return 1 }
@@ -81,6 +90,178 @@ private func craftNativeJustifyCode(_ value: String) -> UInt32 {
81
90
  }
82
91
  }
83
92
 
93
+ /// Optional bridge to the host-neutral tree layout ABI. The host still owns
94
+ /// intrinsic measurement and keeps its local implementation for unsupported
95
+ /// trees or runtimes built before this symbol was exported.
96
+ private enum CraftNativeTreeLayoutBridge {
97
+ private struct StyleInput {
98
+ var direction: UInt32 = 0
99
+ var width: Float = .nan
100
+ var height: Float = .nan
101
+ var intrinsicWidth: Float = .nan
102
+ var intrinsicHeight: Float = .nan
103
+ var baseline: Float = .nan
104
+ var aspectRatio: Float = .nan
105
+ var flexBasis: Float = .nan
106
+ var flexGrow: Float = 0
107
+ var flexShrink: Float = 0
108
+ var minWidth: Float = .nan
109
+ var maxWidth: Float = .nan
110
+ var minHeight: Float = .nan
111
+ var maxHeight: Float = .nan
112
+ var gap: Float = 0
113
+ var justifyContent: UInt32 = 0
114
+ var alignItems: UInt32 = 3
115
+ var alignSelf: UInt32 = 0
116
+ var flexWrap: UInt32 = 0
117
+ var alignContent: UInt32 = 6
118
+ var marginTop: Float = 0
119
+ var marginRight: Float = 0
120
+ var marginBottom: Float = 0
121
+ var marginLeft: Float = 0
122
+ var paddingTop: Float = 0
123
+ var paddingRight: Float = 0
124
+ var paddingBottom: Float = 0
125
+ var paddingLeft: Float = 0
126
+ var grid: UInt32 = 0
127
+ var gridColumns: UInt32 = 1
128
+ var rowGap: Float = 0
129
+ var columnGap: Float = 0
130
+ }
131
+
132
+ private struct NodeInput {
133
+ var style: StyleInput
134
+ var firstChild: UInt32
135
+ var childCount: UInt32
136
+ }
137
+
138
+ private struct Frame {
139
+ var x: Float
140
+ var y: Float
141
+ var width: Float
142
+ var height: Float
143
+ }
144
+
145
+ private typealias Function = @convention(c) (
146
+ Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
147
+ ) -> Int32
148
+
149
+ private static let function: Function? = {
150
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
151
+ return unsafeBitCast(symbol, to: Function.self)
152
+ }()
153
+
154
+ private static func finite(_ value: CGFloat?) -> Float {
155
+ guard let value, value.isFinite else { return .nan }
156
+ return Float(value)
157
+ }
158
+
159
+ private static func length(_ value: CraftNativeLength?, base: CGFloat?) -> Float {
160
+ guard let value else { return .nan }
161
+ return finite(value.resolve(base))
162
+ }
163
+
164
+ private static func alignment(_ value: String?) -> UInt32 {
165
+ switch value {
166
+ case "center": return 1
167
+ case "flex-end": return 2
168
+ case "stretch": return 3
169
+ case "baseline": return 4
170
+ default: return 0
171
+ }
172
+ }
173
+
174
+ private static func alignSelf(_ value: String?) -> UInt32 {
175
+ switch value {
176
+ case "flex-start": return 1
177
+ case "center": return 2
178
+ case "flex-end": return 3
179
+ case "stretch": return 4
180
+ case "baseline": return 5
181
+ default: return 0
182
+ }
183
+ }
184
+
185
+ private static func justify(_ value: String) -> UInt32 {
186
+ craftNativeJustifyCode(value)
187
+ }
188
+
189
+ static func frames(
190
+ width: CGFloat,
191
+ height: CGFloat,
192
+ direction: NSLayoutConstraint.Axis,
193
+ gap: CGFloat,
194
+ rowGap: CGFloat?,
195
+ columnGap: CGFloat?,
196
+ wrap: Bool,
197
+ justifyContent: String,
198
+ alignItems: String,
199
+ padding: UIEdgeInsets,
200
+ children: [(UIView, CraftNativeLayoutStyle, CGSize, CGFloat)]
201
+ ) -> [(UIView, CGRect)]? {
202
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !children.isEmpty else { return nil }
203
+ let mainGap = direction == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
204
+ let crossGap = direction == .horizontal ? (rowGap ?? gap) : (columnGap ?? gap)
205
+ var root = StyleInput(
206
+ direction: direction == .horizontal ? 0 : 1,
207
+ width: Float(width), height: Float(height), gap: Float(max(0, mainGap)),
208
+ justifyContent: justify(justifyContent), alignItems: alignment(alignItems), flexWrap: wrap ? 1 : 0,
209
+ paddingTop: Float(max(0, padding.top)), paddingRight: Float(max(0, padding.right)),
210
+ paddingBottom: Float(max(0, padding.bottom)), paddingLeft: Float(max(0, padding.left))
211
+ )
212
+ root.rowGap = Float(max(0, rowGap ?? 0))
213
+ root.columnGap = Float(max(0, columnGap ?? 0))
214
+ let contentWidth = max(0, width - padding.left - padding.right)
215
+ let contentHeight = max(0, height - padding.top - padding.bottom)
216
+ let mainSize = direction == .horizontal ? contentWidth : contentHeight
217
+ var nodes = [NodeInput(style: root, firstChild: 1, childCount: UInt32(children.count))]
218
+ for (view, style, intrinsic, baseline) in children {
219
+ let shrink: CGFloat
220
+ if let authored = style.flexShrink {
221
+ shrink = authored
222
+ } else if view is UIScrollView {
223
+ shrink = 1
224
+ } else if view is UILabel, direction == .horizontal {
225
+ shrink = 1
226
+ } else {
227
+ shrink = 0
228
+ }
229
+ var input = StyleInput(
230
+ width: length(style.width, base: contentWidth), height: length(style.height, base: contentHeight),
231
+ intrinsicWidth: Float(max(0, intrinsic.width)), intrinsicHeight: Float(max(0, intrinsic.height)),
232
+ baseline: Float(max(0, baseline)), aspectRatio: finite(style.aspectRatio),
233
+ flexBasis: length(style.flexBasis, base: mainSize), flexGrow: Float(max(0, style.flexGrow)), flexShrink: Float(max(0, shrink)),
234
+ minWidth: length(style.minWidth, base: contentWidth), maxWidth: length(style.maxWidth, base: contentWidth),
235
+ minHeight: length(style.minHeight, base: contentHeight), maxHeight: length(style.maxHeight, base: contentHeight),
236
+ alignSelf: alignSelf(style.alignSelf),
237
+ marginTop: Float(max(0, style.marginTop)), marginRight: Float(max(0, style.marginRight)),
238
+ marginBottom: Float(max(0, style.marginBottom)), marginLeft: Float(max(0, style.marginLeft))
239
+ )
240
+ input.direction = 0
241
+ nodes.append(NodeInput(style: input, firstChild: 0, childCount: 0))
242
+ }
243
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
244
+ let frameCount = output.count
245
+ let status = nodes.withUnsafeBufferPointer { nodeBuffer in
246
+ output.withUnsafeMutableBufferPointer { frameBuffer in
247
+ function(
248
+ Float(width), Float(height), UnsafeRawPointer(nodeBuffer.baseAddress!), nodes.count,
249
+ UnsafeMutableRawPointer(frameBuffer.baseAddress!), frameCount
250
+ )
251
+ }
252
+ }
253
+ guard status == 0 else { return nil }
254
+ let childFrames = output.dropFirst()
255
+ guard childFrames.allSatisfy({ frame in
256
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
257
+ frame.width >= 0 && frame.height >= 0
258
+ }) else { return nil }
259
+ return zip(children, childFrames).map { child, frame in
260
+ (child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
261
+ }
262
+ }
263
+ }
264
+
84
265
  private enum CraftNativeFlexLineBridge {
85
266
  private struct Item {
86
267
  var basis: Float
@@ -430,6 +611,7 @@ private final class CraftNativeVideoView: UIView {
430
611
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
431
612
  private var annotationsByID: [String: MKPointAnnotation] = [:]
432
613
  private var overlaysByID: [String: MKPolyline] = [:]
614
+ private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
433
615
  private var overlayColors: [ObjectIdentifier: UIColor] = [:]
434
616
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
435
617
  private(set) var markerHandlers: [String: String] = [:]
@@ -479,7 +661,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
479
661
  }
480
662
  } else if type == "mappolyline" || type == "polyline" {
481
663
  guard points.count > 1 else { continue }
482
- nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), CGFloat((childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3))
664
+ let authoredWidth = (childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3
665
+ let width = CGFloat(authoredWidth)
666
+ guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
667
+ nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
483
668
  }
484
669
  }
485
670
 
@@ -499,13 +684,31 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
499
684
  }
500
685
  markerHandlers = nextHandlers
501
686
 
502
- removeOverlays(Array(overlaysByID.values))
503
- overlaysByID.removeAll(keepingCapacity: true)
504
- overlayColors.removeAll(keepingCapacity: true)
505
- overlayWidths.removeAll(keepingCapacity: true)
687
+ let staleOverlays = overlaysByID.keys.filter { nextPolylines[$0] == nil }
688
+ for id in staleOverlays {
689
+ if let overlay = overlaysByID.removeValue(forKey: id) {
690
+ removeOverlay(overlay)
691
+ overlayColors.removeValue(forKey: ObjectIdentifier(overlay))
692
+ overlayWidths.removeValue(forKey: ObjectIdentifier(overlay))
693
+ }
694
+ overlayCoordinatesByID.removeValue(forKey: id)
695
+ }
506
696
  for (id, value) in nextPolylines {
697
+ if let overlay = overlaysByID[id],
698
+ let coordinates = overlayCoordinatesByID[id],
699
+ Self.coordinatesEqual(coordinates, value.coordinates),
700
+ overlayColors[ObjectIdentifier(overlay)]?.isEqual(value.color) == true,
701
+ overlayWidths[ObjectIdentifier(overlay)] == value.width {
702
+ continue
703
+ }
704
+ if let previous = overlaysByID.removeValue(forKey: id) {
705
+ removeOverlay(previous)
706
+ overlayColors.removeValue(forKey: ObjectIdentifier(previous))
707
+ overlayWidths.removeValue(forKey: ObjectIdentifier(previous))
708
+ }
507
709
  let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
508
710
  overlaysByID[id] = overlay
711
+ overlayCoordinatesByID[id] = value.coordinates
509
712
  overlayColors[ObjectIdentifier(overlay)] = value.color
510
713
  overlayWidths[ObjectIdentifier(overlay)] = value.width
511
714
  addOverlay(overlay)
@@ -557,8 +760,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
557
760
  let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
558
761
  ?? (value as? [NSNumber])?.map(\.doubleValue)
559
762
  ?? []
560
- return stride(from: 0, to: numbers.count - 1, by: 2).map {
561
- CLLocationCoordinate2D(latitude: numbers[$0], longitude: numbers[$0 + 1])
763
+ return stride(from: 0, to: numbers.count - 1, by: 2).compactMap {
764
+ let latitude = numbers[$0]
765
+ let longitude = numbers[$0 + 1]
766
+ guard latitude.isFinite, longitude.isFinite,
767
+ (-90...90).contains(latitude), (-180...180).contains(longitude) else { return nil }
768
+ return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
769
+ }
770
+ }
771
+
772
+ private static func coordinatesEqual(_ lhs: [CLLocationCoordinate2D], _ rhs: [CLLocationCoordinate2D]) -> Bool {
773
+ guard lhs.count == rhs.count else { return false }
774
+ return zip(lhs, rhs).allSatisfy { left, right in
775
+ left.latitude == right.latitude && left.longitude == right.longitude
562
776
  }
563
777
  }
564
778
 
@@ -569,7 +783,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
569
783
  let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
570
784
  let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
571
785
  let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
572
- let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue else { return nil }
786
+ let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue,
787
+ latitude.isFinite, longitude.isFinite, latitudeDelta.isFinite, longitudeDelta.isFinite,
788
+ (-90...90).contains(latitude), (-180...180).contains(longitude),
789
+ latitudeDelta > 0, latitudeDelta <= 180, longitudeDelta > 0, longitudeDelta <= 360,
790
+ latitude - latitudeDelta / 2 >= -90, latitude + latitudeDelta / 2 <= 90 else { return nil }
573
791
  return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
574
792
  }
575
793
 
@@ -631,15 +849,17 @@ private enum CraftNativeLength: Equatable {
631
849
 
632
850
  init?(_ value: Any?) {
633
851
  if let number = value as? NSNumber {
634
- self = .points(CGFloat(truncating: number))
852
+ let points = CGFloat(truncating: number)
853
+ guard points.isFinite else { return nil }
854
+ self = .points(points)
635
855
  return
636
856
  }
637
857
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
638
- if text.hasSuffix("%"), let fraction = Double(text.dropLast()) {
858
+ if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
639
859
  self = .percent(CGFloat(fraction) / 100)
640
- } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)) {
860
+ } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
641
861
  self = .points(CGFloat(points))
642
- } else if let points = Double(text) {
862
+ } else if let points = Double(text), points.isFinite {
643
863
  self = .points(CGFloat(points))
644
864
  } else {
645
865
  return nil
@@ -648,7 +868,7 @@ private enum CraftNativeLength: Equatable {
648
868
 
649
869
  func resolve(_ base: CGFloat?) -> CGFloat? {
650
870
  switch self {
651
- case .points(let value): return value
871
+ case .points(let value): return max(0, value)
652
872
  case .percent(let fraction): return base.map { max(0, $0 * fraction) }
653
873
  }
654
874
  }
@@ -685,7 +905,11 @@ private struct CraftNativeLayoutStyle {
685
905
  static let empty = CraftNativeLayoutStyle([:])
686
906
 
687
907
  init(_ raw: [String: Any]) {
688
- func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
908
+ func number(_ value: Any?) -> CGFloat? {
909
+ guard let number = value as? NSNumber else { return nil }
910
+ let value = CGFloat(truncating: number)
911
+ return value.isFinite ? value : nil
912
+ }
689
913
  width = CraftNativeLength(raw["width"])
690
914
  height = CraftNativeLength(raw["height"])
691
915
  let ratio = number(raw["aspectRatio"])
@@ -987,6 +1211,13 @@ private class CraftNativeFlowView: UIStackView {
987
1211
 
988
1212
  private var flowChildren: [UIView] = []
989
1213
  private var measurements: [MeasureKey: CGSize] = [:]
1214
+ /// The screen's rendered root gets a definite viewport from the host. A
1215
+ /// root with `flex: 1` must keep that height even when its first child is
1216
+ /// a ScrollView whose content is taller than the screen; otherwise an
1217
+ /// absolute sibling is positioned against the scroll content and moves
1218
+ /// with it.
1219
+ private weak var viewportChild: UIView?
1220
+ private var viewportChildFillsHeight = false
990
1221
 
991
1222
  private struct MeasureKey: Hashable {
992
1223
  let width: CGFloat?
@@ -1088,6 +1319,12 @@ private class CraftNativeFlowView: UIStackView {
1088
1319
  invalidateMeasurements()
1089
1320
  }
1090
1321
 
1322
+ func setViewportChild(_ view: UIView?, fillsHeight: Bool) {
1323
+ viewportChild = view
1324
+ viewportChildFillsHeight = fillsHeight
1325
+ invalidateMeasurements()
1326
+ }
1327
+
1091
1328
  func removeLayoutStyle(for view: UIView) {
1092
1329
  view.craftLayoutStyle = .empty
1093
1330
  }
@@ -1122,7 +1359,12 @@ private class CraftNativeFlowView: UIStackView {
1122
1359
  let scale = window?.screen.scale ?? traitCollection.displayScale
1123
1360
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
1124
1361
  for (child, frame) in result.frames {
1125
- var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
1362
+ var viewportFrame = frame
1363
+ if child === viewportChild, viewportChildFillsHeight {
1364
+ viewportFrame.origin.y = 0
1365
+ viewportFrame.size.height = bounds.height
1366
+ }
1367
+ var snapped = CraftNativeFlexLayout.snapped(viewportFrame, scale: scale)
1126
1368
  // Snapping each edge can take a pixel off a label (15.83…33.83
1127
1369
  // becomes 16…33.67), and a label a pixel narrower than its text
1128
1370
  // truncates it ("13" drew as "…"). Text keeps its whole size.
@@ -1205,6 +1447,29 @@ private class CraftNativeFlowView: UIStackView {
1205
1447
  /// outer size when its parent fixes it, nil when it sizes to its content
1206
1448
  /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
1207
1449
  /// placing, every child's frame in its own coordinates.
1450
+ private func sharedDirectFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> [(UIView, CGRect)]? {
1451
+ guard let width, let height, !grid, !reverse, !wrapReverse,
1452
+ width.isFinite, height.isFinite, width >= 0, height >= 0,
1453
+ children.allSatisfy({ child in
1454
+ let style = child.craftLayoutStyle
1455
+ return style.position != "absolute" && style.display != "none"
1456
+ }) else { return nil }
1457
+
1458
+ let intrinsic = children.map { child in
1459
+ CraftNativeFlexLayout.measure(child, width: nil, height: nil, maxWidth: width, maxHeight: height)
1460
+ }
1461
+ let baselines = children.enumerated().map { index, child in
1462
+ CraftNativeFlowView.firstBaseline(child, cross: intrinsic[index].height)
1463
+ }
1464
+ let payload = zip(children, zip(intrinsic, baselines)).map { child, measured in
1465
+ (child, child.craftLayoutStyle, measured.0, measured.1)
1466
+ }
1467
+ return CraftNativeTreeLayoutBridge.frames(
1468
+ width: width, height: height, direction: axis, gap: gap, rowGap: rowGap, columnGap: columnGap, wrap: wrap,
1469
+ justifyContent: justifyContent, alignItems: alignItems, padding: effectivePadding, children: payload
1470
+ )
1471
+ }
1472
+
1208
1473
  private func layout(
1209
1474
  width: CGFloat?,
1210
1475
  height: CGFloat?,
@@ -1239,6 +1504,9 @@ private class CraftNativeFlowView: UIStackView {
1239
1504
  frames: &frames
1240
1505
  )
1241
1506
  }
1507
+ if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
1508
+ frames = shared
1509
+ }
1242
1510
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
1243
1511
 
1244
1512
  if place {
@@ -1286,6 +1554,7 @@ private class CraftNativeFlowView: UIStackView {
1286
1554
  place: Bool,
1287
1555
  frames: inout [(UIView, CGRect)]
1288
1556
  ) -> CGSize {
1557
+ let contentPadding = effectivePadding
1289
1558
  let definiteMain = horizontal ? innerWidth : innerHeight
1290
1559
  let definiteCross = horizontal ? innerHeight : innerWidth
1291
1560
  let roomMain = horizontal ? roomWidth : roomHeight
@@ -1647,6 +1916,7 @@ private class CraftNativeFlowView: UIStackView {
1647
1916
  place: Bool,
1648
1917
  frames: inout [(UIView, CGRect)]
1649
1918
  ) -> CGSize {
1919
+ let contentPadding = effectivePadding
1650
1920
  let columns = max(1, gridColumns)
1651
1921
  let horizontalGap = columnGap ?? gap
1652
1922
  let verticalGap = rowGap ?? gap
@@ -1670,7 +1940,7 @@ private class CraftNativeFlowView: UIStackView {
1670
1940
  let available = max(0, cellWidth - style.marginLeft - style.marginRight)
1671
1941
  let align = style.alignSelf ?? alignItems
1672
1942
  var fixedWidth = style.width?.resolve(cellWidth)
1673
- var fixedHeight = style.height?.resolve(nil)
1943
+ var fixedHeight = style.height?.resolve(innerHeight)
1674
1944
  // A cell's item is as wide as its column unless it sets a width
1675
1945
  // (or an aspect ratio derives one from its height), whatever its
1676
1946
  // height: a fixed-height swatch with no content was drawn 0 wide.
@@ -1704,7 +1974,7 @@ private class CraftNativeFlowView: UIStackView {
1704
1974
  let size = sizes[index]
1705
1975
  let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1706
1976
  var explicitWidth = style.width?.resolve(cellWidth)
1707
- var explicitHeight = style.height?.resolve(nil)
1977
+ var explicitHeight = style.height?.resolve(innerHeight)
1708
1978
  if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
1709
1979
  explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1710
1980
  }
@@ -1713,7 +1983,7 @@ private class CraftNativeFlowView: UIStackView {
1713
1983
  size.width, size.height,
1714
1984
  explicitWidth, explicitHeight,
1715
1985
  style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
1716
- style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
1986
+ style.minHeight?.resolve(innerHeight), style.maxHeight?.resolve(innerHeight),
1717
1987
  style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
1718
1988
  align == "stretch", alignment
1719
1989
  )
@@ -1822,7 +2092,7 @@ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
1822
2092
  func configureKeyboard(props: [String: Any]) {
1823
2093
  let requestedBehavior = (props["behavior"] as? String) ?? "padding"
1824
2094
  keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
1825
- keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
2095
+ keyboardVerticalOffset = number(props["keyboardVerticalOffset"]) ?? 0
1826
2096
  if keyboardBehavior != "height" { appliedHeightInset = 0 }
1827
2097
  authoredPadding = padding
1828
2098
  applyKeyboardAvoidance()
@@ -2323,7 +2593,7 @@ private final class CraftNativeSvgView: UIView {
2323
2593
  let props = drawing["props"] as? [String: Any] ?? [:]
2324
2594
  guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
2325
2595
  let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
2326
- guard n.count == 4, n[2] > 0, n[3] > 0 else { return nil }
2596
+ guard n.count == 4, n.allSatisfy(\.isFinite), n[2] > 0, n[3] > 0 else { return nil }
2327
2597
  return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
2328
2598
  }
2329
2599
 
@@ -2344,21 +2614,29 @@ private final class CraftNativeSvgView: UIView {
2344
2614
  var linecap: String?
2345
2615
  var linejoin: String?
2346
2616
  var dashes: [CGFloat]?
2617
+ var vectorEffect: String?
2347
2618
 
2348
2619
  func merged(_ node: [String: Any]) -> Paint {
2349
2620
  var next = self
2350
2621
  let props = node["props"] as? [String: Any] ?? [:]
2351
2622
  let style = node["style"] as? [String: Any] ?? [:]
2623
+ func value(_ key: String) -> Any? { style[key] ?? props[key] }
2624
+ func finite(_ raw: Any?) -> CGFloat? {
2625
+ guard let number = raw as? NSNumber else { return nil }
2626
+ let value = CGFloat(truncating: number)
2627
+ return value.isFinite ? value : nil
2628
+ }
2352
2629
  if let color = style["color"] as? String { next.color = color }
2353
2630
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
2354
2631
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
2355
- if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
2632
+ if let width = finite(style["strokeWidth"] ?? props["strokeWidth"]) { next.strokeWidth = width }
2356
2633
  // Computed style wins over the component prop, matching Android
2357
2634
  // and the browser's cascade when both provide opacity.
2358
- if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
2359
- if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
2360
- if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
2361
- if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
2635
+ if let opacity = finite(style["opacity"] ?? props["opacity"]) { next.opacity *= opacity }
2636
+ if let cap = value("strokeLinecap") as? String { next.linecap = cap }
2637
+ if let join = value("strokeLinejoin") as? String { next.linejoin = join }
2638
+ if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
2639
+ if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
2362
2640
  return next
2363
2641
  }
2364
2642
 
@@ -2425,7 +2703,7 @@ private final class CraftNativeSvgView: UIView {
2425
2703
  // `vector-effect="non-scaling-stroke"`: the stroke is in the
2426
2704
  // view's points, so a stretched chart's line keeps its width
2427
2705
  // and a round cap stays a round dot.
2428
- let nonScaling = (shape["props"] as? [String: Any])?["vectorEffect"] as? String == "non-scaling-stroke"
2706
+ let nonScaling = own.vectorEffect == "non-scaling-stroke"
2429
2707
  let line: UIBezierPath
2430
2708
  if nonScaling, let copy = path.copy() as? UIBezierPath {
2431
2709
  copy.apply(viewTransform)
@@ -2452,14 +2730,14 @@ private final class CraftNativeSvgView: UIView {
2452
2730
  guard !text.isEmpty else { return }
2453
2731
  let props = node["props"] as? [String: Any] ?? [:]
2454
2732
  let style = node["style"] as? [String: Any] ?? [:]
2455
- let size = CGFloat(truncating: props["fontSize"] as? NSNumber ?? style["fontSize"] as? NSNumber ?? 12)
2733
+ let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
2456
2734
  let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
2457
2735
  let string = NSAttributedString(string: text, attributes: [
2458
2736
  .font: UIFont.systemFont(ofSize: size, weight: weight),
2459
2737
  .foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
2460
2738
  ])
2461
- var x = CGFloat(truncating: props["x"] as? NSNumber ?? 0)
2462
- let y = CGFloat(truncating: props["y"] as? NSNumber ?? 0)
2739
+ var x = number(props["x"]) ?? 0
2740
+ let y = number(props["y"]) ?? 0
2463
2741
  switch props["textAnchor"] as? String {
2464
2742
  case "middle": x -= string.size().width / 2
2465
2743
  case "end": x -= string.size().width
@@ -2472,7 +2750,7 @@ private final class CraftNativeSvgView: UIView {
2472
2750
  static func path(_ value: Any?) -> UIBezierPath? {
2473
2751
  guard let list = value as? [Any] else { return nil }
2474
2752
  let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
2475
- guard !n.isEmpty, n.count == list.count else { return nil }
2753
+ guard !n.isEmpty, n.count == list.count, n.allSatisfy(\.isFinite) else { return nil }
2476
2754
  let path = UIBezierPath()
2477
2755
  var i = 0
2478
2756
  func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
@@ -2909,6 +3187,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2909
3187
  override func viewDidAppear(_ animated: Bool) {
2910
3188
  super.viewDidAppear(animated)
2911
3189
  screenVisibilityChanged(true)
3190
+ // A navigation breadcrumb for the crash reporter: the trail a crash
3191
+ // report carries is the screens that led to it.
3192
+ if !isAppScript, let routeName, !routeName.isEmpty {
3193
+ CraftCrashReporter.shared.screenAppeared(routeName)
3194
+ }
2912
3195
  }
2913
3196
 
2914
3197
  override func viewDidDisappear(_ animated: Bool) {
@@ -3004,7 +3287,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3004
3287
  ?? Self.safeJavaScriptString(exception)
3005
3288
  ?? "unknown"
3006
3289
  let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
3007
- CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
3290
+ let screen = self?.screenName(in: context) ?? "screen"
3291
+ CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
3292
+ // To the app's crash reporter too (bughq, when configured): an
3293
+ // uncaught error in a shipped screen is otherwise only in a log
3294
+ // nobody reads on someone else's phone.
3295
+ CraftCrashReporter.shared.captureJavaScript(
3296
+ name: Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name")),
3297
+ message: message,
3298
+ stack: stack,
3299
+ route: screen,
3300
+ isAppScript: self?.isAppScript ?? false
3301
+ )
3008
3302
  self?.hybridFailed(message)
3009
3303
  }
3010
3304
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
@@ -4232,6 +4526,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4232
4526
  var rootStyle = next.style
4233
4527
  if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
4234
4528
  rootStack.setLayoutStyle(rootStyle, for: next.view)
4529
+ rootStack.setViewportChild(next.view, fillsHeight: rootStyle["height"] == nil)
4235
4530
  rootStack.invalidateMeasurements()
4236
4531
  renderedRoot = next
4237
4532
  rootIsSafeAreaView = next.type == "SafeAreaView"
@@ -4246,6 +4541,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4246
4541
  if let root = renderedRoot { detach(root, from: rootStack) }
4247
4542
  renderedRoot = nil
4248
4543
  rootIsSafeAreaView = false
4544
+ rootStack.setViewportChild(nil, fillsHeight: false)
4249
4545
  rootTopToSafeArea?.isActive = true
4250
4546
  rootTopToView?.isActive = false
4251
4547
  rootBottomToSafeArea?.isActive = true
@@ -4537,10 +4833,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4537
4833
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
4538
4834
  case "Slider":
4539
4835
  let slider = result as! UISlider
4540
- slider.minimumValue = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
4541
- slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, (props["maximumValue"] as? NSNumber)?.floatValue ?? 1)
4542
- let value = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
4543
- if let step = (props["step"] as? NSNumber)?.floatValue, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
4836
+ let minimum = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
4837
+ slider.minimumValue = minimum.isFinite ? minimum : 0
4838
+ let maximum = (props["maximumValue"] as? NSNumber)?.floatValue ?? 1
4839
+ slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, maximum.isFinite ? maximum : 1)
4840
+ let authoredValue = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
4841
+ let value = authoredValue.isFinite ? authoredValue : slider.minimumValue
4842
+ if let step = (props["step"] as? NSNumber)?.floatValue, step.isFinite, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
4544
4843
  else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
4545
4844
  slider.value = snappedSliderValue(value, for: slider)
4546
4845
  slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
@@ -4555,7 +4854,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4555
4854
  indicator.style = (requestedSize as? String) == "large" ? .large : .medium
4556
4855
  let scale: CGFloat
4557
4856
  if let numericSize = requestedSize as? NSNumber {
4558
- scale = max(0.5, CGFloat(numericSize.doubleValue / 20.0))
4857
+ let value = CGFloat(numericSize.doubleValue / 20.0)
4858
+ scale = value.isFinite ? max(0.5, value) : 1
4559
4859
  } else if (requestedSize as? String) == "small" {
4560
4860
  scale = 0.75
4561
4861
  } else {
@@ -4811,6 +5111,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4811
5111
  case "checkbox": return "Checkbox"
4812
5112
  case "textinput", "input": return "TextInput"
4813
5113
  case "picker", "select": return "Picker"
5114
+ case "datepicker", "date-picker", "date": return "DatePicker"
4814
5115
  case "modal": return "Modal"
4815
5116
  case "switch": return "Switch"
4816
5117
  case "slider": return "Slider"
@@ -4950,14 +5251,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4950
5251
  { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
4951
5252
  }
4952
5253
 
5254
+ let threshold = max(
5255
+ 0,
5256
+ Double(number(props["onEndReachedThreshold"]) ?? number(props["threshold"]) ?? 0.1)
5257
+ )
4953
5258
  list.apply(
4954
5259
  nodes: children,
4955
5260
  horizontal: props["horizontal"] as? Bool == true,
4956
5261
  columns: (props["numColumns"] as? NSNumber)?.intValue ?? 1,
4957
5262
  inverted: props["inverted"] as? Bool == true,
4958
- endReachedThreshold: (props["onEndReachedThreshold"] as? NSNumber)?.doubleValue
4959
- ?? (props["threshold"] as? NSNumber)?.doubleValue
4960
- ?? 0.1,
5263
+ endReachedThreshold: threshold,
4961
5264
  renderItem: { [weak self] node, identity, _ in
4962
5265
  guard let self = self else { return UIView() }
4963
5266
  let previous = self.flatListRows[listKey]?[identity]
@@ -5100,7 +5403,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5100
5403
 
5101
5404
  private func emitLayoutEvents(for node: RenderedNode) {
5102
5405
  let id = ObjectIdentifier(node.view)
5103
- if let handler = layoutHandlers[id], node.view.window != nil {
5406
+ if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
5104
5407
  let frame = node.view.frame
5105
5408
  if lastLayoutFrames[id] != frame {
5106
5409
  lastLayoutFrames[id] = frame
@@ -5121,6 +5424,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5121
5424
  }
5122
5425
  }
5123
5426
 
5427
+ private func isEffectivelyVisible(_ view: UIView) -> Bool {
5428
+ guard view.window != nil, !view.isHidden else { return false }
5429
+ var ancestor = view.superview
5430
+ while let current = ancestor {
5431
+ if current.isHidden { return false }
5432
+ ancestor = current.superview
5433
+ }
5434
+ return true
5435
+ }
5436
+
5124
5437
  private func updateAuxiliaryHandler(
5125
5438
  _ handler: String?,
5126
5439
  in handlers: inout [ObjectIdentifier: String],
@@ -5327,10 +5640,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5327
5640
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
5328
5641
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
5329
5642
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
5330
- let gap = number(style["gap"]) ?? 0
5643
+ let gap = max(0, number(style["gap"]) ?? 0)
5331
5644
  stack.gap = gap
5332
- stack.rowGap = number(style["rowGap"]) ?? gap
5333
- stack.columnGap = number(style["columnGap"]) ?? gap
5645
+ stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
5646
+ stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
5334
5647
  stack.grid = style["display"] as? String == "grid"
5335
5648
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
5336
5649
  stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
@@ -5405,14 +5718,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5405
5718
  }
5406
5719
 
5407
5720
  private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
5408
- let padding = number(style["padding"]) ?? 0
5409
- let horizontal = number(style["paddingHorizontal"]) ?? padding
5410
- let vertical = number(style["paddingVertical"]) ?? padding
5721
+ func nonNegative(_ value: Any?) -> CGFloat? {
5722
+ guard let value = number(value), value >= 0 else { return nil }
5723
+ return value
5724
+ }
5725
+ let padding = nonNegative(style["padding"]) ?? 0
5726
+ let horizontal = nonNegative(style["paddingHorizontal"]) ?? padding
5727
+ let vertical = nonNegative(style["paddingVertical"]) ?? padding
5411
5728
  return UIEdgeInsets(
5412
- top: number(style["paddingTop"]) ?? vertical,
5413
- left: number(style["paddingLeft"]) ?? horizontal,
5414
- bottom: number(style["paddingBottom"]) ?? vertical,
5415
- right: number(style["paddingRight"]) ?? horizontal
5729
+ top: nonNegative(style["paddingTop"]) ?? vertical,
5730
+ left: nonNegative(style["paddingLeft"]) ?? horizontal,
5731
+ bottom: nonNegative(style["paddingBottom"]) ?? vertical,
5732
+ right: nonNegative(style["paddingRight"]) ?? horizontal
5416
5733
  )
5417
5734
  }
5418
5735
 
@@ -5672,6 +5989,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5672
5989
  imageSources[id] = uri
5673
5990
  imageErrors.removeValue(forKey: id)
5674
5991
  view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
5992
+ imageDidChange(view)
5675
5993
  view.accessibilityValue = nil
5676
5994
  emitImageEvent(view, handler: imageLoadStartHandlers[id])
5677
5995
 
@@ -5683,6 +6001,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5683
6001
  return
5684
6002
  }
5685
6003
  view.image = imageForDisplay(image, view: view)
6004
+ imageDidChange(view)
5686
6005
  emitImageEvent(view, handler: imageLoadHandlers[id])
5687
6006
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
5688
6007
  return
@@ -5698,6 +6017,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5698
6017
  return
5699
6018
  }
5700
6019
  view.image = self.imageForDisplay(image, view: view)
6020
+ self.imageDidChange(view)
5701
6021
  self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
5702
6022
  self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
5703
6023
  }
@@ -5712,6 +6032,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5712
6032
  return
5713
6033
  }
5714
6034
  view.image = imageForDisplay(image, view: view)
6035
+ imageDidChange(view)
5715
6036
  emitImageEvent(view, handler: imageLoadHandlers[id])
5716
6037
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
5717
6038
  return
@@ -5736,6 +6057,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5736
6057
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
5737
6058
  imageErrors[ObjectIdentifier(view)] = message
5738
6059
  view.image = nil
6060
+ imageDidChange(view)
5739
6061
  view.accessibilityValue = message
5740
6062
  let id = ObjectIdentifier(view)
5741
6063
  emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
@@ -5743,6 +6065,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5743
6065
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
5744
6066
  }
5745
6067
 
6068
+ private func imageDidChange(_ view: UIImageView) {
6069
+ view.invalidateIntrinsicContentSize()
6070
+ CraftNativeFlexLayout.invalidateAncestors(of: view)
6071
+ }
6072
+
5746
6073
  private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
5747
6074
  image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
5748
6075
  }
@@ -5766,7 +6093,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5766
6093
  view.accessibilityValue = nil
5767
6094
  }
5768
6095
  let role = props["accessibilityRole"] as? String
5769
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
6096
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
5770
6097
  var traits: UIAccessibilityTraits = []
5771
6098
  switch role ?? type.lowercased() {
5772
6099
  case "button", "checkbox": traits.insert(.button)
@@ -6000,7 +6327,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6000
6327
  /// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
6001
6328
  static func countdownDuration(_ value: Any?) -> TimeInterval? {
6002
6329
  let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
6003
- if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
6330
+ if let number = value as? NSNumber, !(value is Bool) {
6331
+ let seconds = number.doubleValue
6332
+ guard seconds.isFinite else { return nil }
6333
+ return min(maximum, max(0, seconds))
6334
+ }
6004
6335
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
6005
6336
  if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
6006
6337
  let parts = text.split(separator: ":")
@@ -6008,7 +6339,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6008
6339
  let hours = Double(parts[0]),
6009
6340
  let minutes = Double(parts[1]) else { return nil }
6010
6341
  let seconds = parts.count == 3 ? (Double(parts[2]) ?? -1) : 0
6011
- guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
6342
+ guard hours.isFinite, minutes.isFinite, seconds.isFinite,
6343
+ hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
6012
6344
  return min(maximum, hours * 3600 + minutes * 60 + seconds)
6013
6345
  }
6014
6346
 
@@ -6016,7 +6348,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6016
6348
  /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
6017
6349
  /// milliseconds since 1970. Nil for anything else.
6018
6350
  static func pickerDate(_ value: Any?) -> Date? {
6019
- if let number = value as? NSNumber, !(value is Bool) { return Date(timeIntervalSince1970: number.doubleValue / 1000) }
6351
+ if let number = value as? NSNumber, !(value is Bool) {
6352
+ let milliseconds = number.doubleValue
6353
+ guard milliseconds.isFinite else { return nil }
6354
+ return Date(timeIntervalSince1970: milliseconds / 1000)
6355
+ }
6020
6356
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
6021
6357
  let local = DateFormatter()
6022
6358
  local.locale = Locale(identifier: "en_US_POSIX")
@@ -6174,7 +6510,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6174
6510
  }
6175
6511
 
6176
6512
  private func number(_ value: Any?) -> CGFloat? {
6177
- (value as? NSNumber).map { CGFloat(truncating: $0) }
6513
+ guard let number = value as? NSNumber else { return nil }
6514
+ let value = CGFloat(truncating: number)
6515
+ return value.isFinite ? value : nil
6178
6516
  }
6179
6517
 
6180
6518
  private func keyboardType(_ value: Any?) -> UIKeyboardType {