@craft-native/ios 0.0.475 → 0.0.481

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/README.md CHANGED
@@ -342,6 +342,11 @@ needs a solid bar.
342
342
  The `backgroundColor` navigation option is shared with the Android native host;
343
343
  omitting it or passing `null` returns to the app's dynamic light/dark colour.
344
344
 
345
+ Set `titleColor` to a screen's text colour when its branded palette needs a
346
+ different navigation title contrast. Both native hosts apply it to inline and
347
+ large titles. Passing `null` restores the host's normal title colour; it does
348
+ not change the bar material, button colours, or another screen's appearance.
349
+
345
350
  The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
346
351
 
347
352
  The iOS host overlays `craft.config.json` on its defaults. Missing or `null` capability flags stay disabled; explicitly set a flag to `true` to enable it. Both the Swift host and Zig bridge reject malformed values and fall back to all capabilities disabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.475",
3
+ "version": "0.0.481",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -820,6 +820,14 @@ final class CraftChrome: ObservableObject {
820
820
  /// A compact floating icon, instead of the system full-width accessory shelf.
821
821
  var iconOnly = false
822
822
  var companions: [Tab] = []
823
+ /// A tap opens these destinations in a compact menu instead of navigating.
824
+ var menu: [TabMenuItem] = []
825
+ }
826
+
827
+ struct TabMenuItem: Equatable, Identifiable {
828
+ let id: String
829
+ let title: String
830
+ let symbol: String
823
831
  }
824
832
 
825
833
  @Published var tabs: [Tab] = []
@@ -965,7 +973,13 @@ final class CraftChrome: ObservableObject {
965
973
  companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
966
974
  let companionTitle = action["title"] as? String else { return nil }
967
975
  return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
968
- accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true)
976
+ accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true,
977
+ menu: ((action["menu"] as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
978
+ guard let destination = choice["path"] as? String,
979
+ destination.hasPrefix("/"), !destination.hasPrefix("//"),
980
+ let label = choice["title"] as? String, !label.isEmpty else { return nil }
981
+ return TabMenuItem(id: destination, title: label, symbol: choice["symbol"] as? String ?? "circle")
982
+ })
969
983
  })
970
984
  } else {
971
985
  next = nil
@@ -1273,35 +1287,46 @@ private struct CraftTabBarContent: View {
1273
1287
  @ScaledMetric(relativeTo: .caption2) private var labelSize: CGFloat = 11
1274
1288
  @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
1275
1289
 
1290
+ private func accessoryLabel(_ accessory: CraftChrome.Tab) -> some View {
1291
+ surface {
1292
+ Image(systemName: accessory.symbol)
1293
+ .font(.system(size: iconSize))
1294
+ .foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
1295
+ .frame(width: 62, height: 62)
1296
+ .background {
1297
+ if chrome.accessorySelected(accessory) {
1298
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1299
+ }
1300
+ }
1301
+ }
1302
+ .contentShape(Rectangle())
1303
+ .overlay(alignment: .topTrailing) {
1304
+ if let badge = accessory.badge {
1305
+ Text(badge)
1306
+ .font(.system(size: badgeSize, weight: .bold))
1307
+ .foregroundStyle(.white)
1308
+ .padding(.horizontal, 4)
1309
+ .frame(minWidth: 18, minHeight: 18)
1310
+ .background(Capsule().fill(Color.red))
1311
+ }
1312
+ }
1313
+ }
1276
1314
  var body: some View {
1277
1315
  if !chrome.tabs.isEmpty {
1278
1316
  HStack(spacing: 8) {
1279
1317
  bar
1280
1318
  if let primary = chrome.tabAccessory {
1281
1319
  ForEach(primary.companions + [primary]) { accessory in
1282
- Button { CraftHybrid.shared.open(accessory.id) } label: {
1283
- surface {
1284
- Image(systemName: accessory.symbol)
1285
- .font(.system(size: iconSize))
1286
- .foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
1287
- .frame(width: 62, height: 62)
1288
- .background {
1289
- if chrome.accessorySelected(accessory) {
1290
- Circle().fill(selectedColor.opacity(0.14)).padding(4)
1291
- }
1320
+ Group {
1321
+ if accessory.menu.isEmpty {
1322
+ Button { CraftHybrid.shared.open(accessory.id) } label: { accessoryLabel(accessory) }
1323
+ } else {
1324
+ Menu {
1325
+ ForEach(accessory.menu) { choice in
1326
+ Button { CraftHybrid.shared.open(choice.id) } label: { Label(choice.title, systemImage: choice.symbol) }
1292
1327
  }
1328
+ } label: { accessoryLabel(accessory) }
1293
1329
  }
1294
- .contentShape(Rectangle())
1295
- .overlay(alignment: .topTrailing) {
1296
- if let badge = accessory.badge {
1297
- Text(badge)
1298
- .font(.system(size: badgeSize, weight: .bold))
1299
- .foregroundStyle(.white)
1300
- .padding(.horizontal, 4)
1301
- .frame(minWidth: 18, minHeight: 18)
1302
- .background(Capsule().fill(Color.red))
1303
- }
1304
- }
1305
1330
  }
1306
1331
  .buttonStyle(.plain)
1307
1332
  .accessibilityShowsLargeContentViewer {
@@ -1430,7 +1430,17 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1430
1430
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
1431
1431
  button.accessibilityValue = action.badge
1432
1432
  button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1433
- button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1433
+ if action.menu.isEmpty {
1434
+ button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1435
+ } else {
1436
+ button.menu = UIMenu(children: action.menu.map { choice in
1437
+ UIAction(title: choice.title, image: UIImage(systemName: choice.symbol)) { [weak self] _ in
1438
+ self?.hybrid.open(choice.id)
1439
+ }
1440
+ })
1441
+ button.showsMenuAsPrimaryAction = true
1442
+ if #available(iOS 16.0, *) { button.preferredMenuElementOrder = .fixed }
1443
+ }
1434
1444
  let glass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1435
1445
  #if compiler(>=6.2)
1436
1446
  if #available(iOS 26.0, *) { glass.effect = UIGlassEffect() }
@@ -36,6 +36,7 @@ enum CraftNativeActions {
36
36
  private static var biometricContexts = [String: LAContext]()
37
37
  #endif
38
38
  private static var fetchTasks = [String: URLSessionTask]()
39
+ private static var fetchProgress = [String: NSKeyValueObservation]()
39
40
  private static let fetchSession: URLSession = {
40
41
  let configuration = URLSessionConfiguration.default
41
42
  configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
@@ -50,6 +51,7 @@ enum CraftNativeActions {
50
51
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
51
52
  // as it arrives) need nothing the app has to declare.
52
53
  var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
54
+ values.append("uploadProgress")
53
55
  values.append("network")
54
56
  values.append("nativePager")
55
57
  values.append("storageCompareAndSet")
@@ -180,6 +182,7 @@ enum CraftNativeActions {
180
182
  method: String,
181
183
  args: [Any],
182
184
  config: CraftConfig,
185
+ onUploadProgress: (([String: Any]) -> Void)? = nil,
183
186
  completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
184
187
  ) {
185
188
  cancellationLock.lock()
@@ -293,7 +296,9 @@ enum CraftNativeActions {
293
296
  CraftWebServices.perform(action: "getNetworkStatus", body: [:]) { complete(requestToken, $0, completion) }
294
297
  return
295
298
  case ("Network", "fetch"):
296
- fetch(requestToken: requestToken, args: args, completion: completion)
299
+ DispatchQueue.global(qos: .utility).async {
300
+ fetch(requestToken: requestToken, args: args, onUploadProgress: onUploadProgress, completion: completion)
301
+ }
297
302
  return
298
303
  case ("Files", "pickImage"):
299
304
  // A person picks, so the bridge never times it out.
@@ -783,6 +788,7 @@ enum CraftNativeActions {
783
788
  let context = biometricContexts.removeValue(forKey: requestToken)
784
789
  #endif
785
790
  let task = fetchTasks.removeValue(forKey: requestToken)
791
+ fetchProgress.removeValue(forKey: requestToken)
786
792
  cancellationLock.unlock()
787
793
  #if CRAFT_PERMISSION_BIOMETRIC
788
794
  context?.invalidate()
@@ -798,8 +804,13 @@ enum CraftNativeActions {
798
804
  private static func fetch(
799
805
  requestToken: String,
800
806
  args: [Any],
807
+ onUploadProgress: (([String: Any]) -> Void)?,
801
808
  completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
802
809
  ) {
810
+ guard !isCancelled(requestToken) else {
811
+ complete(requestToken, failure("CANCELLED", "Upload cancelled"), completion)
812
+ return
813
+ }
803
814
  guard let options = args.first as? [String: Any],
804
815
  let address = options["url"] as? String,
805
816
  let url = URL(string: address),
@@ -855,6 +866,7 @@ enum CraftNativeActions {
855
866
  if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
856
867
  cancellationLock.lock()
857
868
  fetchTasks.removeValue(forKey: requestToken)
869
+ fetchProgress.removeValue(forKey: requestToken)
858
870
  cancellationLock.unlock()
859
871
  if let error = error {
860
872
  let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
@@ -891,12 +903,22 @@ enum CraftNativeActions {
891
903
  }
892
904
  let task: URLSessionTask = uploadFile.map { fetchSession.uploadTask(with: request, fromFile: $0, completionHandler: responseHandler) }
893
905
  ?? fetchSession.dataTask(with: request, completionHandler: responseHandler)
906
+ let uploadID = options["uploadProgressId"] as? String ?? ""
907
+ let expectedBytes = uploadFile.flatMap { try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize }.map { Int64($0) } ?? Int64(request.httpBody?.count ?? 0)
908
+ let observation = !uploadID.isEmpty && onUploadProgress != nil ? task.observe(\.countOfBytesSent, options: [.initial, .new]) { task, _ in
909
+ let total = max(task.countOfBytesExpectedToSend, expectedBytes)
910
+ guard total > 0 else { return }
911
+ onUploadProgress?(["id": uploadID, "loaded": min(max(0, task.countOfBytesSent), total), "total": total])
912
+ } : nil
894
913
  cancellationLock.lock()
895
914
  let alreadyCancelled = cancelledRequests.contains(requestToken)
896
- if !alreadyCancelled { fetchTasks[requestToken] = task }
915
+ if !alreadyCancelled { fetchTasks[requestToken] = task; fetchProgress[requestToken] = observation }
897
916
  cancellationLock.unlock()
898
917
  if !alreadyCancelled { task.resume() }
899
- else if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
918
+ else {
919
+ if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
920
+ complete(requestToken, failure("CANCELLED", "Upload cancelled"), completion)
921
+ }
900
922
  }
901
923
 
902
924
  /// `craft.files.download`: a GET straight to a file made here (for the
@@ -7359,10 +7359,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7359
7359
  /// for every screen.
7360
7360
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7361
7361
  /// `craft.secureStorage.set` wrote.
7362
- /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
7363
- /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
7364
- /// backgroundColor, navigationBarSolid, rightButtons, tabAccessory })`;
7365
- /// a right button's tap is the event `navButton`.
7362
+ /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7363
+ /// backTitle, tabBarHidden, tabItem, swipeBack, contentSwipeBack,
7364
+ /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7365
+ /// rightButtons, tabAccessory })`; a button's tap is the event
7366
+ /// `navButton`. `craft.d.ts` types no navigation surface, so this list
7367
+ /// is the public description of it — keep it in step with the keys
7368
+ /// `setOptions` reads, which is how `leftButtons`, `tabItem` and
7369
+ /// `titleColor` came to ship undocumented.
7366
7370
  ///
7367
7371
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
7368
7372
  /// these: each namespace is a property whose setter adds them back to the
@@ -7856,6 +7860,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7856
7860
  /// the home indicator too, where its content never reaches: a black
7857
7861
  /// full-screen timer is black edge to edge. nil is the app's.
7858
7862
  private var screenBackgroundColor: UIColor?
7863
+ /// Title contrast belongs to this screen, independently of the bar material.
7864
+ private var navigationTitleColor: UIColor?
7859
7865
 
7860
7866
  override var preferredStatusBarStyle: UIStatusBarStyle {
7861
7867
  statusBarStyleOverride ?? .default
@@ -7926,6 +7932,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7926
7932
  if let solid = options["navigationBarSolid"] as? Bool {
7927
7933
  navigationBarSolid = solid
7928
7934
  }
7935
+ if options.keys.contains("titleColor") {
7936
+ navigationTitleColor = color(options["titleColor"])
7937
+ }
7929
7938
  if let hidden = options["hidden"] as? Bool {
7930
7939
  navigationBarHidden = hidden
7931
7940
  if navigationController?.topViewController === self {
@@ -7941,7 +7950,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7941
7950
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
7942
7951
  navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
7943
7952
  }
7944
- if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
7953
+ if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") || options.keys.contains("titleColor") {
7945
7954
  applyNavigationBarAppearance()
7946
7955
  trackContentScrollView()
7947
7956
  }
@@ -7952,25 +7961,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7952
7961
  }
7953
7962
  }
7954
7963
 
7955
- /// Applies the screen's optional opaque bar without changing the default
7956
- /// UIKit material when the screen did not opt in.
7964
+ /// Per-item appearances preserve each UIKit bar state and never recolour a
7965
+ /// sibling controller's title or opt a material bar into an opaque fill.
7957
7966
  private func applyNavigationBarAppearance() {
7958
- guard navigationBarSolid else {
7967
+ guard navigationBarSolid || navigationTitleColor != nil else {
7959
7968
  navigationItem.standardAppearance = nil
7960
7969
  navigationItem.scrollEdgeAppearance = nil
7961
7970
  navigationItem.compactAppearance = nil
7962
7971
  navigationItem.compactScrollEdgeAppearance = nil
7963
7972
  return
7964
7973
  }
7965
- let appearance = UINavigationBarAppearance()
7966
- appearance.configureWithOpaqueBackground()
7967
- appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7968
- appearance.shadowColor = .clear
7969
- CraftChromeFonts.apply(to: appearance)
7970
- navigationItem.standardAppearance = appearance
7971
- navigationItem.scrollEdgeAppearance = appearance
7972
- navigationItem.compactAppearance = appearance
7973
- navigationItem.compactScrollEdgeAppearance = appearance
7974
+ let bar = navigationController?.navigationBar
7975
+ let standard = bar?.standardAppearance ?? UINavigationBarAppearance()
7976
+ // UIKit's unspecified scroll-edge appearance uses a transparent
7977
+ // background. Copy its standard text/button attributes before applying
7978
+ // that background so adding title contrast preserves the same states.
7979
+ let scrollEdge = bar?.scrollEdgeAppearance ?? {
7980
+ let appearance = UINavigationBarAppearance(barAppearance: standard)
7981
+ appearance.configureWithTransparentBackground()
7982
+ return appearance
7983
+ }()
7984
+ func styled(_ base: UINavigationBarAppearance) -> UINavigationBarAppearance {
7985
+ let appearance = UINavigationBarAppearance(barAppearance: base)
7986
+ if navigationBarSolid {
7987
+ appearance.configureWithOpaqueBackground()
7988
+ appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7989
+ appearance.shadowColor = .clear
7990
+ }
7991
+ CraftChromeFonts.apply(to: appearance)
7992
+ if let navigationTitleColor {
7993
+ appearance.titleTextAttributes[.foregroundColor] = navigationTitleColor
7994
+ appearance.largeTitleTextAttributes[.foregroundColor] = navigationTitleColor
7995
+ }
7996
+ return appearance
7997
+ }
7998
+ navigationItem.standardAppearance = styled(standard)
7999
+ navigationItem.scrollEdgeAppearance = styled(scrollEdge)
8000
+ navigationItem.compactAppearance = styled(bar?.compactAppearance ?? standard)
8001
+ navigationItem.compactScrollEdgeAppearance = styled(bar?.compactScrollEdgeAppearance ?? scrollEdge)
7974
8002
  }
7975
8003
 
7976
8004
  private func applyStatusBar(_ props: [String: Any]) {
@@ -8700,6 +8728,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8700
8728
  method: method,
8701
8729
  args: args,
8702
8730
  config: config,
8731
+ onUploadProgress: { [weak self] progress in
8732
+ DispatchQueue.main.async {
8733
+ guard let self, self.pendingCapabilityRequests.contains(requestToken), !self.runtimeDisposed else { return }
8734
+ self.send(type: "NETWORK_UPLOAD_PROGRESS", payload: progress)
8735
+ }
8736
+ },
8703
8737
  completion: reply
8704
8738
  )
8705
8739
  case "API_CANCEL":