craft-native 0.0.293 → 0.0.299

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.
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
102
102
  }
103
103
  }
104
104
 
105
+ /// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
106
+ /// only controls the SwiftUI environment; native screen controllers need the
107
+ /// UIKit override as well so their trait collection, controls and navigation
108
+ /// chrome resolve the same way. A runtime choice is kept here so screens
109
+ /// created after the choice inherit it instead of reverting to the bundle
110
+ /// config.
111
+ enum CraftInterfaceStyleController {
112
+ /// The app's bundled choice is the default for newly-created screens.
113
+ /// A runtime choice is separate: it is the only value that should
114
+ /// override a screen's own generated config.
115
+ private static var configured: UIUserInterfaceStyle = .unspecified
116
+ private static var runtimeSelection: UIUserInterfaceStyle?
117
+
118
+ static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
119
+ switch config.appearance {
120
+ case "system": return .unspecified
121
+ case "light": return .light
122
+ case "dark": return .dark
123
+ default: return config.darkMode ? .dark : .light
124
+ }
125
+ }
126
+
127
+ static func configure(_ config: CraftConfig) {
128
+ configured = configuredStyle(for: config)
129
+ runtimeSelection = nil
130
+ applyToWindows()
131
+ }
132
+
133
+ static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
134
+ runtimeSelection ?? configuredStyle(for: config)
135
+ }
136
+
137
+ static func colorScheme(for config: CraftConfig) -> ColorScheme? {
138
+ switch style(for: config) {
139
+ case .light: return .light
140
+ case .dark: return .dark
141
+ default: return nil
142
+ }
143
+ }
144
+
145
+ /// Returns false rather than silently accepting a typo from JavaScript.
146
+ @discardableResult
147
+ static func set(_ value: String) -> Bool {
148
+ let style: UIUserInterfaceStyle
149
+ switch value {
150
+ case "system": style = .unspecified
151
+ case "light": style = .light
152
+ case "dark": style = .dark
153
+ default: return false
154
+ }
155
+ runtimeSelection = style
156
+ applyToWindows()
157
+ NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
158
+ return true
159
+ }
160
+
161
+ static func currentName() -> String {
162
+ let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
163
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
164
+ .first ?? .unspecified
165
+ let style = requested == .unspecified
166
+ ? (UIApplication.shared.connectedScenes
167
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
168
+ .first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
169
+ : requested
170
+ return style == .dark ? "dark" : "light"
171
+ }
172
+
173
+ static func apply(to controller: UIViewController, config: CraftConfig) {
174
+ controller.overrideUserInterfaceStyle = style(for: config)
175
+ }
176
+
177
+ private static func applyToWindows() {
178
+ let style = runtimeSelection ?? configured
179
+ for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
180
+ for window in scene.windows {
181
+ window.overrideUserInterfaceStyle = style
182
+ apply(style, to: window.rootViewController)
183
+ }
184
+ }
185
+ }
186
+
187
+ private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
188
+ guard let controller else { return }
189
+ controller.overrideUserInterfaceStyle = style
190
+ controller.children.forEach { apply(style, to: $0) }
191
+ if let presented = controller.presentedViewController { apply(style, to: presented) }
192
+ }
193
+ }
194
+
105
195
  /// Tells the shell when SwiftUI's colour scheme changes: the one appearance
106
196
  /// setting with no notification of its own.
107
197
  struct CraftColorSchemeObserver: View {
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
195
285
  final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
196
286
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
197
287
  let config = CraftConfig.bundled()
288
+ CraftInterfaceStyleController.configure(config)
198
289
  // First, so a crash anywhere in the rest of launch is reported too.
199
290
  CraftCrashReporter.shared.start(config)
200
291
  UNUserNotificationCenter.current().delegate = self
@@ -606,7 +697,7 @@ struct CraftApp: App {
606
697
  .ignoresSafeArea(.keyboard, edges: .bottom)
607
698
  }
608
699
  }
609
- .preferredColorScheme(appState.config.colorScheme)
700
+ .preferredColorScheme(appState.appearanceScheme)
610
701
  .environmentObject(appState)
611
702
  .onOpenURL { url in
612
703
  // Handle deep links and universal links
@@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject {
689
780
  /// The selected tab highlight's corner radius. Nil preserves the default
690
781
  /// capsule, while a finite non-negative value opts into a rounded rect.
691
782
  private(set) var tabSelectionCornerRadius: CGFloat?
783
+ /// The configured static shape, retained when the page updates only its
784
+ /// tabs or selected item at runtime.
785
+ private var configuredTabSelectionCornerRadius: CGFloat?
786
+ private(set) var tabSelectionColor: UIColor?
692
787
 
693
788
  weak var webView: WKWebView?
694
789
  /// What the tab bar covers at the bottom of the screen, in points.
@@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject {
723
818
  /// The tab bar from `tabs` in craft.config.json, there before the page
724
819
  /// loads; the page's own `tabBar` message replaces it later. `splash`
725
820
  /// false when the first screen is native and draws itself.
726
- func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
821
+ func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
727
822
  splashVisible = splash
728
823
  tabSwipeEnabled = true
824
+ configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
825
+ .flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
826
+ tabSelectionCornerRadius = configuredTabSelectionCornerRadius
827
+ tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
729
828
  guard !configured.isEmpty else { return }
730
829
  tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
731
830
  self.selected = selected ?? configured.first?.id
@@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject {
856
955
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
857
956
  }
858
957
  if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
958
+ if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
859
959
  withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
860
960
  publishLayout()
861
961
  case "selectTab":
@@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape {
1312
1412
  }
1313
1413
 
1314
1414
  private struct CraftTabSelectionIndicator: View {
1415
+ @ObservedObject private var chrome = CraftChrome.shared
1315
1416
  @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1316
1417
  let selected: Int?
1317
1418
  let count: Int
@@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View {
1323
1424
  let width = geometry.size.width / CGFloat(max(1, count))
1324
1425
  CraftTabSelectionShape(cornerRadius: cornerRadius)
1325
1426
  .frame(width: width, height: geometry.size.height)
1326
- .foregroundStyle(Color.primary.opacity(0.09))
1427
+ .foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
1327
1428
  .offset(x: position * width)
1328
1429
  }
1329
1430
  }
@@ -1557,6 +1658,8 @@ class DeepLinkManager {
1557
1658
  // MARK: - App State
1558
1659
  class AppState: ObservableObject {
1559
1660
  @Published var config: CraftConfig
1661
+ @Published private(set) var appearanceScheme: ColorScheme?
1662
+ private var appearanceObserver: NSObjectProtocol?
1560
1663
 
1561
1664
  init() {
1562
1665
  if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
@@ -1565,11 +1668,26 @@ class AppState: ObservableObject {
1565
1668
  } else {
1566
1669
  self.config = CraftConfig()
1567
1670
  }
1671
+ self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config)
1672
+ appearanceObserver = NotificationCenter.default.addObserver(
1673
+ forName: .craftColorSchemeChanged,
1674
+ object: nil,
1675
+ queue: .main,
1676
+ ) { [weak self] _ in
1677
+ guard let self else { return }
1678
+ self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config)
1679
+ }
1568
1680
  // Before the first frame: the hybrid start screen, and the tab bar
1569
1681
  // from the config, are decided here, if the app delegate has not
1570
1682
  // already (it does at launch; the first call decides).
1571
1683
  CraftHybrid.shared.configure(config)
1572
1684
  }
1685
+
1686
+ deinit {
1687
+ if let appearanceObserver {
1688
+ NotificationCenter.default.removeObserver(appearanceObserver)
1689
+ }
1690
+ }
1573
1691
  }
1574
1692
 
1575
1693
  extension CraftConfig {
@@ -1706,6 +1824,9 @@ struct CraftConfig: Codable {
1706
1824
  var nativeScreens: [String: String]? = nil
1707
1825
  /// The tab bar to show before the page describes its own.
1708
1826
  var tabs: [Tab]? = nil
1827
+ /// Static styling for the selected tab indicator; the page may still
1828
+ /// override its shape through `tabBar.set({ selection })`.
1829
+ var tabBar: TabBar? = nil
1709
1830
  /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1710
1831
  /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1711
1832
  var shareStorage: [String: String]? = nil
@@ -1733,6 +1854,15 @@ struct CraftConfig: Codable {
1733
1854
  var selectedIconPulse: Bool? = nil
1734
1855
  }
1735
1856
 
1857
+ struct TabBar: Codable {
1858
+ var selection: TabSelection? = nil
1859
+ }
1860
+
1861
+ struct TabSelection: Codable {
1862
+ var cornerRadius: Double? = nil
1863
+ var color: String? = nil
1864
+ }
1865
+
1736
1866
  struct BackgroundRefresh: Codable {
1737
1867
  var enabled: Bool = false
1738
1868
  /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
@@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable {
2535
2665
  resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
2536
2666
  case "statusBarSetStyle":
2537
2667
  resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
2668
+ case "setAppearance":
2669
+ guard let appearance = body["appearance"] as? String else {
2670
+ rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2671
+ return
2672
+ }
2673
+ guard CraftInterfaceStyleController.set(appearance) else {
2674
+ rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2675
+ return
2676
+ }
2677
+ resolveCallback(callbackId, result: true)
2678
+ case "getAppearance":
2679
+ resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
2538
2680
  case "chromeSetUnderPageColor":
2539
2681
  // What shows past the page's edges when it rubber-bands.
2540
2682
  if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
@@ -5596,6 +5738,14 @@ struct CraftWebView: UIViewRepresentable {
5596
5738
  getInfo: function() { return craft.getDeviceInfo(); },
5597
5739
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
5598
5740
  };
5741
+ craft.app = Object.assign(craft.app || {}, {
5742
+ setAppearance: function(appearance) {
5743
+ return craft._invoke('setAppearance', {appearance: appearance});
5744
+ },
5745
+ getAppearance: function() {
5746
+ return craft._invoke('getAppearance', {});
5747
+ }
5748
+ });
5599
5749
  // Feedback, as on Android and the web, where there is no
5600
5750
  // motor to fire: an app that left enableHaptics off gets
5601
5751
  // nothing played and a settled promise, so `await
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
380
380
  }
381
381
  startsNative = isEnabled && routes.match(startPath) != nil
382
382
  // The splash waits for a page; an all-native app's first screen draws itself.
383
- CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
383
+ CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative, appearance: config.tabBar)
384
384
 
385
385
  let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
386
386
  appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
@@ -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", "viewNonModalPresentation"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
46
46
  values.append("storageCompareAndSet")
47
47
  values.append("storageSetIfAbsent")
48
48
  values.append("viewScroll")