craft-native 0.0.254 → 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.
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +65 -4
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftHealthServices.kt.template +132 -0
- package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
- package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
- package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +5 -0
- package/dist/android/templates/MainActivityNative.kt.template +212 -23
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +19 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +36 -4
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +30 -7
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +32 -3
- package/dist/ios/templates/CraftNativeScreen.swift +288 -10
- package/dist/mobile.js +1 -1
- 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
|
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 =
|
|
1592
|
+
let nav = CraftNativeNavigationController(rootViewController: root)
|
|
1570
1593
|
nav.delegate = self
|
|
1571
1594
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1572
1595
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|
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.
|