craft-native 0.0.251 → 0.0.262

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 (30) hide show
  1. package/dist/android/src/index.d.ts +4 -2
  2. package/dist/android/src/index.js +69 -5
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  5. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  6. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  7. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  8. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +0 -1
  10. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  11. package/dist/android/templates/CraftNativeShell.kt.template +73 -23
  12. package/dist/android/templates/MainActivityNative.kt.template +217 -28
  13. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  14. package/dist/api/mobile.d.ts +9 -1
  15. package/dist/cli.js +1 -1
  16. package/dist/components/index.d.ts +19 -10
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/templates/CraftApp.swift +36 -4
  20. package/dist/ios/templates/CraftHealth.swift +38 -0
  21. package/dist/ios/templates/CraftHybrid.swift +38 -8
  22. package/dist/ios/templates/CraftNativeActions.swift +1 -1
  23. package/dist/ios/templates/CraftNativeFlatList.swift +34 -20
  24. package/dist/ios/templates/CraftNativeScreen.swift +308 -28
  25. package/dist/ios/templates/testing/DriverTests.swift.template +21 -1
  26. package/dist/mobile-media-testing.d.ts +2 -0
  27. package/dist/mobile-testing.js +19 -10
  28. package/dist/mobile.js +1 -1
  29. package/dist/testing/media-cache/media-cache.kt +47 -6
  30. package/package.json +1 -1
@@ -686,6 +686,9 @@ final class CraftChrome: ObservableObject {
686
686
  /// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
687
687
  /// Native screens use their own per-screen `tabSwipeEnabled` option.
688
688
  private(set) var tabSwipeEnabled = true
689
+ /// The selected tab highlight's corner radius. Nil preserves the default
690
+ /// capsule, while a finite non-negative value opts into a rounded rect.
691
+ private(set) var tabSelectionCornerRadius: CGFloat?
689
692
 
690
693
  weak var webView: WKWebView?
691
694
  /// What the tab bar covers at the bottom of the screen, in points.
@@ -823,6 +826,7 @@ final class CraftChrome: ObservableObject {
823
826
  switch body["type"] as? String {
824
827
  case "tabBar":
825
828
  pendingHide?.cancel()
829
+ tabSelectionCornerRadius = Self.selectionCornerRadius(body["selection"])
826
830
  tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
827
831
  guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
828
832
  let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
@@ -872,6 +876,15 @@ final class CraftChrome: ObservableObject {
872
876
  }
873
877
  }
874
878
 
879
+ private static func selectionCornerRadius(_ value: Any?) -> CGFloat? {
880
+ guard let selection = value as? [String: Any],
881
+ let raw = selection["cornerRadius"] as? NSNumber,
882
+ raw.doubleValue.isFinite,
883
+ raw.doubleValue >= 0,
884
+ raw.doubleValue <= Double(CGFloat.greatestFiniteMagnitude) else { return nil }
885
+ return CGFloat(raw.doubleValue)
886
+ }
887
+
875
888
  /// A tap on a tab: the page navigates, and says which tab is current.
876
889
  ///
877
890
  /// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
@@ -1175,7 +1188,7 @@ struct CraftTabBarView: View {
1175
1188
  }
1176
1189
  .foregroundStyle(isSelected ? selectedColor : Color.primary)
1177
1190
  .frame(maxWidth: .infinity, minHeight: 54)
1178
- .contentShape(Capsule())
1191
+ .contentShape(CraftTabSelectionShape(cornerRadius: chrome.tabSelectionCornerRadius))
1179
1192
  }
1180
1193
  .buttonStyle(.plain)
1181
1194
  .accessibilityShowsLargeContentViewer {
@@ -1188,7 +1201,11 @@ struct CraftTabBarView: View {
1188
1201
  }
1189
1202
  }
1190
1203
  .background {
1191
- CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
1204
+ CraftTabSelectionIndicator(
1205
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1206
+ count: chrome.tabs.count,
1207
+ cornerRadius: chrome.tabSelectionCornerRadius
1208
+ )
1192
1209
  }
1193
1210
  }
1194
1211
 
@@ -1230,18 +1247,33 @@ struct CraftTabBarView: View {
1230
1247
  }
1231
1248
  }
1232
1249
 
1250
+ private struct CraftTabSelectionShape: Shape {
1251
+ let cornerRadius: CGFloat?
1252
+
1253
+ func path(in rect: CGRect) -> Path {
1254
+ if let cornerRadius {
1255
+ return RoundedRectangle(
1256
+ cornerRadius: min(cornerRadius, min(rect.width, rect.height) / 2),
1257
+ style: .continuous
1258
+ ).path(in: rect)
1259
+ }
1260
+ return Capsule().path(in: rect)
1261
+ }
1262
+ }
1263
+
1233
1264
  private struct CraftTabSelectionIndicator: View {
1234
1265
  @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1235
1266
  let selected: Int?
1236
1267
  let count: Int
1268
+ let cornerRadius: CGFloat?
1237
1269
 
1238
1270
  var body: some View {
1239
1271
  GeometryReader { geometry in
1240
1272
  if let position = progress.position ?? selected.map({ CGFloat($0) }) {
1241
1273
  let width = geometry.size.width / CGFloat(max(1, count))
1242
- Capsule()
1243
- .fill(Color.primary.opacity(0.09))
1274
+ CraftTabSelectionShape(cornerRadius: cornerRadius)
1244
1275
  .frame(width: width, height: geometry.size.height)
1276
+ .foregroundStyle(Color.primary.opacity(0.09))
1245
1277
  .offset(x: position * width)
1246
1278
  }
1247
1279
  }
@@ -43,6 +43,19 @@ private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelega
43
43
  }
44
44
  }
45
45
 
46
+ @available(iOS 26.0, *)
47
+ private final class CraftHealthWorkoutBuilderDelegate: NSObject, HKLiveWorkoutBuilderDelegate {
48
+ weak var owner: CraftHealth?
49
+
50
+ init(owner: CraftHealth) { self.owner = owner }
51
+
52
+ func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
53
+ owner?.emitLiveWorkoutMetrics(workoutBuilder)
54
+ }
55
+
56
+ func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
57
+ }
58
+
46
59
  /// Apple Health for the whole app: the web page's bridge (`craft.health` in
47
60
  /// the hybrid's web view, CraftApp.swift) and native screens alike
48
61
  /// (`Health` through CraftNativeActions), so a screen drawn natively reads
@@ -837,12 +850,15 @@ final class CraftHealth {
837
850
  let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
838
851
  let builder = session.associatedWorkoutBuilder()
839
852
  let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
853
+ let metricsDelegate = CraftHealthWorkoutBuilderDelegate(owner: self)
840
854
  session.delegate = delegate
855
+ builder.delegate = metricsDelegate
841
856
  builder.shouldCollectWorkoutEvents = true
842
857
  builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
843
858
  liveWorkoutSession = session
844
859
  liveWorkoutBuilder = builder
845
860
  liveWorkoutDelegate = delegate
861
+ liveWorkoutBuilderDelegate = metricsDelegate
846
862
  liveWorkoutID = UUID().uuidString.lowercased()
847
863
  liveWorkoutType = type
848
864
  liveWorkoutStart = Date()
@@ -923,11 +939,32 @@ final class CraftHealth {
923
939
  liveWorkoutSession = nil
924
940
  liveWorkoutBuilder = nil
925
941
  liveWorkoutDelegate = nil
942
+ liveWorkoutBuilderDelegate = nil
926
943
  liveWorkoutID = nil
927
944
  liveWorkoutType = nil
928
945
  liveWorkoutStart = nil
929
946
  }
930
947
 
948
+ @available(iOS 26.0, *)
949
+ fileprivate func emitLiveWorkoutMetrics(_ builder: HKLiveWorkoutBuilder) {
950
+ guard let id = liveWorkoutID else { return }
951
+ var detail: [String: Any] = ["id": id, "event": "metrics", "date": Date().timeIntervalSince1970 * 1000]
952
+ let bpm = HKUnit.count().unitDivided(by: .minute())
953
+ if let type = HKQuantityType.quantityType(forIdentifier: .heartRate),
954
+ let value = builder.statistics(for: type)?.mostRecentQuantity()?.doubleValue(for: bpm), value.isFinite {
955
+ detail["heartRateBpm"] = value
956
+ }
957
+ if let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
958
+ let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .kilocalorie()), value.isFinite {
959
+ detail["activeEnergyCalories"] = value
960
+ }
961
+ if let type = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
962
+ let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .meter()), value.isFinite {
963
+ detail["distanceMeters"] = value
964
+ }
965
+ onMain { [weak self] in self?.sendWorkout(detail) }
966
+ }
967
+
931
968
  private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
932
969
  switch type {
933
970
  case "running": return .running
@@ -965,6 +1002,7 @@ final class CraftHealth {
965
1002
  private var liveWorkoutSession: AnyObject?
966
1003
  private var liveWorkoutBuilder: AnyObject?
967
1004
  private var liveWorkoutDelegate: AnyObject?
1005
+ private var liveWorkoutBuilderDelegate: AnyObject?
968
1006
  private var liveWorkoutID: String?
969
1007
  private var liveWorkoutType: String?
970
1008
  private var liveWorkoutStart: Date?
@@ -1076,6 +1076,18 @@ final class CraftHybridWebSlot: UIViewController {
1076
1076
  }
1077
1077
  }
1078
1078
 
1079
+ /// A native stack lets the top screen consume Back for its presented modal
1080
+ /// before UIKit removes the screen from the navigation stack.
1081
+ final class CraftNativeNavigationController: UINavigationController {
1082
+ override func popViewController(animated: Bool) -> UIViewController? {
1083
+ if let screen = topViewController as? CraftNativeScreenController,
1084
+ screen.handleBackForModal() {
1085
+ return nil
1086
+ }
1087
+ return super.popViewController(animated: animated)
1088
+ }
1089
+ }
1090
+
1079
1091
  final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
1080
1092
  private let config: CraftConfig
1081
1093
  private let hybrid = CraftHybrid.shared
@@ -1175,6 +1187,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1175
1187
  tabSwipeRecognizers.append(recognizer)
1176
1188
  }
1177
1189
 
1190
+ private func shouldCompleteTabSwipe(progress: CGFloat, speed: CGFloat) -> Bool {
1191
+ speed > -700 && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1192
+ }
1193
+
1178
1194
  @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1179
1195
  // A finger that lands while the last swipe settles takes the screens
1180
1196
  // from where they are, as a paging scroll view does. This swipe used
@@ -1186,7 +1202,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1186
1202
  let translation = recognizer.translation(in: view).x
1187
1203
  let velocity = recognizer.velocity(in: view).x
1188
1204
  switch recognizer.state {
1189
- case .changed:
1205
+ case .began, .changed:
1206
+ // Catching a settle can begin at zero translation. Keep its
1207
+ // committed selection until the new finger actually moves.
1208
+ if recognizer.state == .began, caughtSettle != nil, abs(translation) <= 1 { return }
1190
1209
  if activeTabSwipeTarget == nil {
1191
1210
  let direction = abs(translation) > 1 ? translation : velocity
1192
1211
  guard abs(direction) > 1 else { return }
@@ -1201,9 +1220,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1201
1220
  // the caught screens finish where they were going.
1202
1221
  if caughtSettle != nil { settleCaughtScreens(); return }
1203
1222
  guard recognizer.state == .ended else { return }
1204
- let delta = abs(translation) >= 32 ? translation : velocity
1205
- guard abs(delta) >= 32 else { return }
1206
- let next = delta < 0 ? index + 1 : index - 1
1223
+ // UIKit can coalesce a short gesture without a changed
1224
+ // callback. Keep the same completion rule as the animated
1225
+ // path instead of treating any 32-point drag as a full page.
1226
+ let direction = abs(translation) > 1 ? translation : velocity
1227
+ guard abs(direction) > 1 else { return }
1228
+ let sign: CGFloat = direction < 0 ? -1 : 1
1229
+ guard shouldCompleteTabSwipe(progress: translation * sign, speed: velocity * sign) else { return }
1230
+ let next = direction < 0 ? index + 1 : index - 1
1207
1231
  guard tabs.indices.contains(next) else { return }
1208
1232
  CraftChrome.shared.swipe(to: tabs[next])
1209
1233
  return
@@ -1216,8 +1240,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1216
1240
  let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
1217
1241
  let progress = (translation + tabSwipeBase) * direction
1218
1242
  let speed = velocity * direction
1219
- let complete = recognizer.state == .ended && speed > -700
1220
- && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1243
+ let complete = recognizer.state == .ended && shouldCompleteTabSwipe(progress: progress, speed: speed)
1221
1244
  finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
1222
1245
  CraftFramePacing.shared.end("tab swipe")
1223
1246
  self?.activeTabSwipeTarget = nil
@@ -1566,7 +1589,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1566
1589
  // Constructing the navigation controller can load the root's view and
1567
1590
  // mount its script immediately. Set its initial focus before that.
1568
1591
  if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
1569
- let nav = UINavigationController(rootViewController: root)
1592
+ let nav = CraftNativeNavigationController(rootViewController: root)
1570
1593
  nav.delegate = self
1571
1594
  nav.view.backgroundColor = config.resolvedBackgroundColor
1572
1595
  nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
@@ -2080,7 +2103,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2080
2103
  /// The edge swipe goes ahead of the web view's own gestures, which
2081
2104
  /// otherwise hold every touch over the page until the page has answered it.
2082
2105
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2083
- gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
2106
+ if gestureRecognizer === visibleStack?.interactivePopGestureRecognizer { return true }
2107
+ guard tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }),
2108
+ let scroll = otherGestureRecognizer.view as? UIScrollView,
2109
+ otherGestureRecognizer === scroll.panGestureRecognizer,
2110
+ scroll.isScrollEnabled else { return false }
2111
+ // A vertical list must not suppress the shell's horizontal tab pan.
2112
+ // Horizontal content keeps ownership of its own drag instead.
2113
+ return !scroll.alwaysBounceHorizontal && scroll.contentSize.width <= scroll.bounds.width + 1
2084
2114
  }
2085
2115
 
2086
2116
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -42,7 +42,7 @@ enum CraftNativeActions {
42
42
  static func capabilities(config: CraftConfig) -> [String] {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
46
46
  values.append("storageCompareAndSet")
47
47
  values.append("storageSetIfAbsent")
48
48
  values.append("viewScroll")
@@ -63,11 +63,22 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
63
63
  onRecycle = nil
64
64
  }
65
65
 
66
+ /// Whether the row view this cell is meant to show is in it. UIKit
67
+ /// can prepare a second cell for a row (a prefetch, an update), and
68
+ /// the row's one view then moves there: the first cell keeps its
69
+ /// height and draws nothing.
70
+ var showsHostedView: Bool { hostedView.map { $0.superview === contentView } ?? true }
71
+
66
72
  func host(_ view: UIView, identity: String) {
67
73
  if let shown = representedIdentity, shown != identity { recycle() }
68
74
  representedIdentity = identity
69
- guard hostedView !== view else { return }
70
- hostedView?.removeFromSuperview()
75
+ // The same view, already here. A view that left for another cell
76
+ // and is asked for again is put back below.
77
+ guard hostedView !== view || view.superview !== contentView else { return }
78
+ // Only a view still in this cell is taken out of it: one that has
79
+ // since moved to another cell is that cell's now, and pulling it
80
+ // from there blanked a row on screen.
81
+ if let shown = hostedView, shown !== view, shown.superview === contentView { shown.removeFromSuperview() }
71
82
  hostedView = view
72
83
  view.translatesAutoresizingMaskIntoConstraints = false
73
84
  contentView.addSubview(view)
@@ -81,10 +92,16 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
81
92
 
82
93
  func releaseHostedView() {
83
94
  recycle()
84
- hostedView?.removeFromSuperview()
95
+ if let shown = hostedView, shown.superview === contentView { shown.removeFromSuperview() }
85
96
  hostedView = nil
86
97
  }
87
98
 
99
+ /// Puts this cell's row view back when another cell took it.
100
+ func reclaimHostedView() {
101
+ guard let identity = representedIdentity, let view = hostedView, view.superview !== contentView else { return }
102
+ host(view, identity: identity)
103
+ }
104
+
88
105
  override func preferredLayoutAttributesFitting(
89
106
  _ layoutAttributes: UICollectionViewLayoutAttributes
90
107
  ) -> UICollectionViewLayoutAttributes {
@@ -279,10 +296,22 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
279
296
  override func layoutSubviews() {
280
297
  updateItemEstimate()
281
298
  super.layoutSubviews()
299
+ reclaimVisibleRows()
282
300
  craftApplyCornerRadius()
283
301
  scrollToPendingTarget()
284
302
  }
285
303
 
304
+ /// A row on screen whose view went to a cell UIKit prepared for the same
305
+ /// row and then did not show gets it back, rather than staying blank.
306
+ private func reclaimVisibleRows() {
307
+ for case let cell as Cell in visibleCells where !cell.showsHostedView { cell.reclaimHostedView() }
308
+ }
309
+
310
+ /// Every row on screen draws its own view (simulator-hosted XCTest).
311
+ var visibleRowsShowTheirViews: Bool {
312
+ visibleCells.allSatisfy { ($0 as? Cell)?.showsHostedView ?? true }
313
+ }
314
+
286
315
  /// Scroll to a virtualized row identified by its native testID, id, or key.
287
316
  /// The row need not be mounted yet; the data snapshot is enough to find its
288
317
  /// index, and UIKit will mount it as part of the scroll.
@@ -371,24 +400,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
371
400
  @objc private func keyboardTap() {
372
401
  guard keyboardShouldPersistTaps != "always" else { return }
373
402
  let point = keyboardTapGesture.location(in: self)
374
- var hit = hitTest(point, with: nil)
375
- var onControl = false
376
- var onButton = false
377
- while let view = hit, view !== self {
378
- if view is UIButton { onButton = true }
379
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
380
- hit = view.superview
403
+ craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
404
+ self.craftScreenController?.forgetFocusedInput()
381
405
  }
382
- if keyboardShouldPersistTaps == "handled", onControl { return }
383
- // A button keeps the keyboard: its press gives the focus straight
384
- // back. Ending editing first only let the keyboard's inset go while
385
- // the finger was down, the button moved out from under it, and the
386
- // press was lost.
387
- if onButton { return }
388
- // Otherwise the keyboard goes; after a tap on nothing, for good (the
389
- // next render must not bring the last input back).
390
- if !onControl { craftScreenController?.forgetFocusedInput() }
391
- window?.endEditing(true)
392
406
  }
393
407
 
394
408
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {