@use-voltra/ios-client 2.3.1 → 2.4.0

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 (132) hide show
  1. package/build/commonjs/dynamic-widget/api.js +28 -1
  2. package/build/commonjs/dynamic-widget/api.js.map +1 -1
  3. package/build/commonjs/index.js +7 -12
  4. package/build/commonjs/index.js.map +1 -1
  5. package/build/commonjs/native/NativeVoltra.js.map +1 -1
  6. package/build/commonjs/utils/useUpdateOnHMR.js +6 -14
  7. package/build/commonjs/utils/useUpdateOnHMR.js.map +1 -1
  8. package/build/module/dynamic-widget/api.js +26 -0
  9. package/build/module/dynamic-widget/api.js.map +1 -1
  10. package/build/module/index.js +1 -2
  11. package/build/module/index.js.map +1 -1
  12. package/build/module/native/NativeVoltra.js.map +1 -1
  13. package/build/module/utils/useUpdateOnHMR.js +1 -13
  14. package/build/module/utils/useUpdateOnHMR.js.map +1 -1
  15. package/build/typescript/commonjs/dynamic-widget/api.d.ts +12 -0
  16. package/build/typescript/commonjs/dynamic-widget/api.d.ts.map +1 -1
  17. package/build/typescript/commonjs/index.d.ts +1 -2
  18. package/build/typescript/commonjs/index.d.ts.map +1 -1
  19. package/build/typescript/commonjs/native/NativeVoltra.d.ts +2 -0
  20. package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
  21. package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts +1 -1
  22. package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts.map +1 -1
  23. package/build/typescript/module/dynamic-widget/api.d.ts +12 -0
  24. package/build/typescript/module/dynamic-widget/api.d.ts.map +1 -1
  25. package/build/typescript/module/index.d.ts +1 -2
  26. package/build/typescript/module/index.d.ts.map +1 -1
  27. package/build/typescript/module/native/NativeVoltra.d.ts +2 -0
  28. package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
  29. package/build/typescript/module/utils/useUpdateOnHMR.d.ts +1 -1
  30. package/build/typescript/module/utils/useUpdateOnHMR.d.ts.map +1 -1
  31. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +4 -0
  32. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -1
  33. package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js +9 -1
  34. package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js.map +1 -1
  35. package/expo-plugin/build/cjs/ios-widget/files/index.js +8 -1
  36. package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
  37. package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js +16 -4
  38. package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js.map +1 -1
  39. package/expo-plugin/build/cjs/ios-widget/files/localization.js +167 -0
  40. package/expo-plugin/build/cjs/ios-widget/files/localization.js.map +1 -0
  41. package/expo-plugin/build/cjs/ios-widget/files/swift.js +68 -7
  42. package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
  43. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js +4 -1
  44. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js.map +1 -1
  45. package/expo-plugin/build/cjs/utils/fileDiscovery.js +7 -4
  46. package/expo-plugin/build/cjs/utils/fileDiscovery.js.map +1 -1
  47. package/expo-plugin/build/cjs/validation.js +1 -0
  48. package/expo-plugin/build/cjs/validation.js.map +1 -1
  49. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +4 -0
  50. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -1
  51. package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js +9 -1
  52. package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js.map +1 -1
  53. package/expo-plugin/build/esm/ios-widget/files/index.js +8 -1
  54. package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
  55. package/expo-plugin/build/esm/ios-widget/files/infoPlist.js +16 -4
  56. package/expo-plugin/build/esm/ios-widget/files/infoPlist.js.map +1 -1
  57. package/expo-plugin/build/esm/ios-widget/files/localization.js +128 -0
  58. package/expo-plugin/build/esm/ios-widget/files/localization.js.map +1 -0
  59. package/expo-plugin/build/esm/ios-widget/files/swift.js +69 -8
  60. package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
  61. package/expo-plugin/build/esm/ios-widget/xcode/groups.js +5 -2
  62. package/expo-plugin/build/esm/ios-widget/xcode/groups.js.map +1 -1
  63. package/expo-plugin/build/esm/utils/fileDiscovery.js +6 -3
  64. package/expo-plugin/build/esm/utils/fileDiscovery.js.map +1 -1
  65. package/expo-plugin/build/esm/validation.js +2 -1
  66. package/expo-plugin/build/esm/validation.js.map +1 -1
  67. package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -1
  68. package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts.map +1 -1
  69. package/expo-plugin/build/types/ios-widget/files/index.d.ts +1 -0
  70. package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
  71. package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts +3 -1
  72. package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts.map +1 -1
  73. package/expo-plugin/build/types/ios-widget/files/localization.d.ts +46 -0
  74. package/expo-plugin/build/types/ios-widget/files/localization.d.ts.map +1 -0
  75. package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
  76. package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts.map +1 -1
  77. package/expo-plugin/build/types/types.d.ts +13 -4
  78. package/expo-plugin/build/types/types.d.ts.map +1 -1
  79. package/expo-plugin/build/types/utils/fileDiscovery.d.ts +2 -0
  80. package/expo-plugin/build/types/utils/fileDiscovery.d.ts.map +1 -1
  81. package/expo-plugin/build/types/validation.d.ts.map +1 -1
  82. package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +4 -0
  83. package/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts +9 -1
  84. package/expo-plugin/src/ios-widget/files/index.ts +14 -1
  85. package/expo-plugin/src/ios-widget/files/infoPlist.ts +23 -4
  86. package/expo-plugin/src/ios-widget/files/localization.ts +176 -0
  87. package/expo-plugin/src/ios-widget/files/swift.ts +88 -10
  88. package/expo-plugin/src/ios-widget/xcode/groups.ts +5 -2
  89. package/expo-plugin/src/types.ts +13 -3
  90. package/expo-plugin/src/utils/fileDiscovery.ts +7 -3
  91. package/expo-plugin/src/validation.ts +2 -0
  92. package/ios/Package.swift +8 -1
  93. package/ios/VoltraWidget.podspec +1 -1
  94. package/ios/app/NativeVoltra.mm +15 -2
  95. package/ios/app/VoltraModule.swift +11 -0
  96. package/ios/app/VoltraModuleImpl.swift +14 -0
  97. package/ios/app/VoltraViewComponentView.mm +2 -2
  98. package/ios/app/VoltraViewRoot.swift +6 -12
  99. package/ios/shared/ShortNames.swift +1 -0
  100. package/ios/shared/VoltraConstants.swift +4 -0
  101. package/ios/shared/VoltraElement.swift +43 -2
  102. package/ios/shared/VoltraLocaleEnvironment.swift +185 -0
  103. package/ios/shared/VoltraModifierDescriptor.swift +33 -0
  104. package/ios/shared/VoltraWidgetDefaults.swift +24 -0
  105. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +50 -28
  106. package/ios/target/VoltraClientWidgetRuntime.swift +12 -1
  107. package/ios/target/VoltraHomeWidget.swift +10 -15
  108. package/ios/target/VoltraWidget.swift +26 -18
  109. package/ios/ui/Extensions/VoltraNode+View.swift +7 -0
  110. package/ios/ui/Helpers/VoltraDeepLinkResolver+URL.swift +37 -0
  111. package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +1 -32
  112. package/ios/ui/Modifiers/BuiltInModifiers.swift +613 -0
  113. package/ios/ui/Modifiers/VoltraElement+nativeModifiers.swift +53 -0
  114. package/ios/ui/Modifiers/VoltraModifierRegistry.swift +181 -0
  115. package/ios/ui/Modifiers/VoltraRootDefaults.swift +100 -0
  116. package/ios/ui/Style/TextStyle.swift +4 -2
  117. package/ios/ui/Style/View+applyStyle.swift +1 -1
  118. package/ios/ui/Views/VoltraText.swift +4 -8
  119. package/package.json +4 -4
  120. package/src/dynamic-widget/api.ts +28 -0
  121. package/src/index.ts +6 -2
  122. package/src/native/NativeVoltra.ts +2 -0
  123. package/src/utils/useUpdateOnHMR.ts +1 -16
  124. package/build/commonjs/utils/helpers.js +0 -25
  125. package/build/commonjs/utils/helpers.js.map +0 -1
  126. package/build/module/utils/helpers.js +0 -4
  127. package/build/module/utils/helpers.js.map +0 -1
  128. package/build/typescript/commonjs/utils/helpers.d.ts +0 -2
  129. package/build/typescript/commonjs/utils/helpers.d.ts.map +0 -1
  130. package/build/typescript/module/utils/helpers.d.ts +0 -2
  131. package/build/typescript/module/utils/helpers.d.ts.map +0 -1
  132. package/src/utils/helpers.ts +0 -1
@@ -7,6 +7,9 @@ import { logger } from '@use-voltra/expo-plugin'
7
7
  /** Table name matches basename without extension; co-located per locale in *.lproj */
8
8
  export const VOLTRA_WIDGET_STRINGS_BASENAME = 'VoltraWidgets.strings'
9
9
 
10
+ /** Default table: Edit Widget sheet strings, and the file that makes each `.lproj` a real resource. */
11
+ export const VOLTRA_LOCALIZABLE_STRINGS_BASENAME = 'Localizable.strings'
12
+
10
13
  /**
11
14
  * Scans the widget extension target directory and returns categorized file lists.
12
15
  *
@@ -43,9 +46,10 @@ export function getIOSWidgetExtensionFiles(targetPath: string, targetName: strin
43
46
  if (!entry.endsWith('.lproj')) {
44
47
  continue
45
48
  }
46
- const stringsPath = path.join(targetPath, entry, VOLTRA_WIDGET_STRINGS_BASENAME)
47
- if (fs.existsSync(stringsPath)) {
48
- widgetFiles.localizedStringResources.push(`${entry}/${VOLTRA_WIDGET_STRINGS_BASENAME}`)
49
+ for (const basename of [VOLTRA_WIDGET_STRINGS_BASENAME, VOLTRA_LOCALIZABLE_STRINGS_BASENAME]) {
50
+ if (fs.existsSync(path.join(targetPath, entry, basename))) {
51
+ widgetFiles.localizedStringResources.push(`${entry}/${basename}`)
52
+ }
49
53
  }
50
54
  }
51
55
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  validateHomeScreenWidgetId,
3
3
  validateInitialStatePath,
4
+ validateWidgetConfigurationCopy,
4
5
  validateWidgetEntry,
5
6
  validateWidgetLabel,
6
7
  validateWidgetServerUpdate,
@@ -55,6 +56,7 @@ export function validateIOSWidgetConfig(widget: IOSWidgetConfig, projectRoot?: s
55
56
  }
56
57
 
57
58
  validateWidgetServerUpdate(widget.serverUpdate, widget.id, iosServerUpdateRules(widget))
59
+ validateWidgetConfigurationCopy(widget, { requireParameterTitle: true })
58
60
 
59
61
  if (widget.supportedFamilies) {
60
62
  if (!Array.isArray(widget.supportedFamilies)) {
package/ios/Package.swift CHANGED
@@ -68,6 +68,7 @@ let package = Package(
68
68
  "JSONValue.swift",
69
69
  "VoltraConfig.swift",
70
70
  "VoltraConstants.swift",
71
+ "VoltraLocaleEnvironment.swift",
71
72
  "VoltraWidgetKind.swift",
72
73
  "VoltraPayloadMigrator.swift",
73
74
  "VoltraRegion.swift",
@@ -76,6 +77,7 @@ let package = Package(
76
77
  // by `ui/VoltraNode+View.swift`, which the podspecs compile alongside them.
77
78
  "ShortNames.swift",
78
79
  "VoltraElement.swift",
80
+ "VoltraModifierDescriptor.swift",
79
81
  "VoltraNode.swift",
80
82
  ]
81
83
  ),
@@ -86,18 +88,23 @@ let package = Package(
86
88
  ),
87
89
  .target(
88
90
  name: "VoltraStyleCore",
91
+ dependencies: ["VoltraSharedCore"],
89
92
  path: "ui",
90
93
  sources: [
91
94
  "Style/BackgroundValue.swift",
92
95
  "Style/JSColorParser.swift",
93
96
  "Style/JSGradientParser.swift",
97
+ "Modifiers/VoltraModifierRegistry.swift",
98
+ "Modifiers/BuiltInModifiers.swift",
99
+ "Modifiers/VoltraElement+nativeModifiers.swift",
100
+ "Helpers/VoltraDeepLinkResolver+URL.swift",
94
101
  ]
95
102
  ),
96
103
  .testTarget(
97
104
  name: "VoltraStyleTests",
98
105
  dependencies: ["VoltraStyleCore"],
99
106
  path: "tests",
100
- sources: ["JSGradientParserTests.swift"]
107
+ sources: ["JSGradientParserTests.swift", "NativeModifierTests.swift"]
101
108
  ),
102
109
  ]
103
110
  )
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
14
14
  :ios => '16.4',
15
15
  }
16
16
  s.swift_version = '5.9'
17
- s.module_name = 'VoltraRuntime'
17
+ s.module_name = 'VoltraWidgetRuntime'
18
18
  s.source = { git: 'https://github.com/callstackincubator/voltra' }
19
19
  s.static_framework = true
20
20
 
@@ -1,8 +1,8 @@
1
1
  #import "NativeVoltra.h"
2
2
  #import <UIKit/UIKit.h>
3
3
 
4
- #if __has_include("Voltra/VoltraRuntime-Swift.h")
5
- #import "Voltra/VoltraRuntime-Swift.h"
4
+ #if __has_include("VoltraRuntime/VoltraRuntime-Swift.h")
5
+ #import "VoltraRuntime/VoltraRuntime-Swift.h"
6
6
  #else
7
7
  #import "VoltraRuntime-Swift.h"
8
8
  #endif
@@ -375,6 +375,19 @@ static void VoltraRejectPromise(RCTPromiseRejectBlock reject, NSString *fallback
375
375
  }];
376
376
  }
377
377
 
378
+ - (void)setDynamicWidgetLocale:(NSString *)tag
379
+ resolve:(RCTPromiseResolveBlock)resolve
380
+ reject:(RCTPromiseRejectBlock)reject
381
+ {
382
+ [self.module setDynamicWidgetLocale:tag completion:^(NSError *error) {
383
+ if (error) {
384
+ reject(@"setDynamicWidgetLocale", error.localizedDescription, error);
385
+ } else {
386
+ resolve(nil);
387
+ }
388
+ }];
389
+ }
390
+
378
391
  - (void)scheduleWidget:(NSString *)widgetId
379
392
  timelineJson:(NSString *)timelineJson
380
393
  resolve:(RCTPromiseResolveBlock)resolve
@@ -245,6 +245,17 @@ public enum VoltraErrors: Error, CustomNSError {
245
245
  }
246
246
  }
247
247
 
248
+ @objc public func setDynamicWidgetLocale(_ tag: String?, completion: @escaping (Error?) -> Void) {
249
+ Task {
250
+ do {
251
+ try await impl.setDynamicWidgetLocale(tag: tag)
252
+ completion(nil)
253
+ } catch {
254
+ completion(error)
255
+ }
256
+ }
257
+ }
258
+
248
259
  @objc public func updateWidget(
249
260
  _ widgetId: String,
250
261
  jsonString: String,
@@ -342,6 +342,20 @@ public class VoltraModuleImpl {
342
342
  )
343
343
  }
344
344
 
345
+ /// Stores the app's language override in the App Group, where the widget extension reads it as
346
+ /// `env.appLocale` (ADR 0009 §3), then reloads every widget and re-renders running Dynamic Live
347
+ /// Activities so it takes effect at once. Throws `WidgetError.appGroupNotConfigured` without a
348
+ /// `groupIdentifier`, since the extension could never see the value.
349
+ func setDynamicWidgetLocale(tag: String?) async throws {
350
+ try VoltraWidgetDefaults.setDynamicWidgetLocale(tag)
351
+ VoltraWidgetService.reloadAllTimelines()
352
+ // Re-send each running Dynamic Live Activity its current content so WidgetKit renders it again
353
+ // with the new env.appLocale (the render cache is keyed on the locale environment). This is the
354
+ // release-safe half of `reloadDynamicLiveActivities`, which is DEBUG-only because it also
355
+ // refetches bundles from Metro.
356
+ await VoltraDynamicLiveActivityRegistry.shared.reload(definitionIds: nil)
357
+ }
358
+
345
359
  func updateWidget(widgetId: String, jsonString: String, options: UpdateWidgetOptions?) async throws {
346
360
  try VoltraWidgetService.setWidgetData(widgetId: widgetId, jsonString: jsonString, deepLinkUrl: options?.deepLinkUrl)
347
361
 
@@ -1,7 +1,7 @@
1
1
  #import "VoltraViewComponentView.h"
2
2
 
3
- #if __has_include("Voltra/VoltraRuntime-Swift.h")
4
- #import "Voltra/VoltraRuntime-Swift.h"
3
+ #if __has_include("VoltraRuntime/VoltraRuntime-Swift.h")
4
+ #import "VoltraRuntime/VoltraRuntime-Swift.h"
5
5
  #else
6
6
  #import "VoltraRuntime-Swift.h"
7
7
  #endif
@@ -3,7 +3,7 @@ import SwiftUI
3
3
  import UIKit
4
4
 
5
5
  @objc public class VoltraViewRoot: UIView {
6
- private var hostingController: UIHostingController<AnyView>?
6
+ private var hostingController: UIHostingController<Voltra>?
7
7
  private var root: VoltraNode = .empty
8
8
  private var viewId: String = UUID().uuidString
9
9
 
@@ -20,8 +20,7 @@ import UIKit
20
20
  }
21
21
 
22
22
  private func setupHostingController() {
23
- let view = Voltra(root: .empty, activityId: viewId)
24
- let hostingController = UIHostingController(rootView: AnyView(view))
23
+ let hostingController = UIHostingController(rootView: Voltra(root: .empty, activityId: viewId))
25
24
  hostingController.view.backgroundColor = .clear
26
25
  addSubview(hostingController.view)
27
26
  self.hostingController = hostingController
@@ -49,15 +48,10 @@ import UIKit
49
48
  updateView()
50
49
  }
51
50
 
51
+ /// Updates the existing hosting controller in place. SwiftUI then diffs the new tree against the
52
+ /// previous one, so `animation`, `transition` and `contentTransition` modifiers animate between
53
+ /// payloads and view state survives updates, as it does in widgets and Live Activities.
52
54
  private func updateView() {
53
- hostingController?.view.removeFromSuperview()
54
-
55
- let newView = Voltra(root: root, activityId: viewId)
56
- let newHostingController = UIHostingController(rootView: AnyView(newView))
57
- newHostingController.view.backgroundColor = .clear
58
- newHostingController.view.frame = bounds
59
- addSubview(newHostingController.view)
60
-
61
- hostingController = newHostingController
55
+ hostingController?.rootView = Voltra(root: root, activityId: viewId)
62
56
  }
63
57
  }
@@ -117,6 +117,7 @@ public enum ShortNames {
117
117
  "minvl": "minimumValueLabel",
118
118
  "minl": "minLength",
119
119
  "minw": "minWidth",
120
+ "mods": "modifiers",
120
121
  "md": "monospacedDigit",
121
122
  "mta": "multilineTextAlignment",
122
123
  "n": "name",
@@ -52,6 +52,10 @@ public enum VoltraStorageKeys {
52
52
  /// React-free, so it can't resolve the URL itself; the app writes it via RCTBundleURLProvider.
53
53
  public static let devServerURL = "Voltra_DevServerURL"
54
54
 
55
+ /// BCP-47 tag the app chose with `setDynamicWidgetLocale`, surfaced to widgets as
56
+ /// `env.appLocale` (ADR 0009 §3). Absent when the app has not set one.
57
+ public static let dynamicWidgetLocale = "Voltra_DynamicWidgetLocale"
58
+
55
59
  // MARK: - Info.plist keys
56
60
 
57
61
  public static let widgetIds = "Voltra_WidgetIds"
@@ -20,11 +20,20 @@ public struct VoltraElement: Hashable {
20
20
  /// Style dictionary with expanded keys, resolved once while parsing.
21
21
  private let _style: [String: JSONValue]?
22
22
 
23
+ /// The `modifiers` prop, decoded once while parsing because renderers read it on every body
24
+ /// evaluation. Derived from `_props`, so identity leaves it out like `_style`.
25
+ public let nativeModifiers: [VoltraModifierDescriptor]
26
+
27
+ /// Element-shaped props, such as a Gauge label, parsed once while parsing and keyed by their
28
+ /// expanded name. `componentProp(_:)` reads them, and so do the hosts that look for native
29
+ /// modifiers anywhere in the rendered tree. Derived from `_props`, like `_style`.
30
+ public let propNodes: [String: VoltraNode]
31
+
23
32
  // MARK: - Hashable
24
33
 
25
34
  /// `_props` carries fully resolved values, so it alone distinguishes elements that render
26
- /// differently. Identity deliberately omits `_style`, which is derived from `_props` and would
27
- /// only restate it.
35
+ /// differently. Identity deliberately omits `_style`, `nativeModifiers` and `propNodes`, which are derived from `_props`
36
+ /// and would only restate it.
28
37
  public func hash(into hasher: inout Hasher) {
29
38
  hasher.combine(type)
30
39
  hasher.combine(id)
@@ -106,15 +115,47 @@ public struct VoltraElement: Hashable {
106
115
 
107
116
  _props = resolvedProps
108
117
  _style = Self.expandedStyle(from: resolvedProps)
118
+ nativeModifiers = VoltraModifierDescriptor.parseList((resolvedProps?["mods"] ?? resolvedProps?["modifiers"])?.stringValue)
119
+ propNodes = Self.elementNodes(in: resolvedProps)
109
120
  }
110
121
 
111
122
  /// Get component prop by name - handles both single component and array
112
123
  public func componentProp(_ propName: String) -> VoltraNode {
124
+ if let node = propNodes[propName] {
125
+ return node
126
+ }
113
127
  guard let propValue = props?[propName] else { return .empty }
114
128
 
115
129
  return VoltraNode(from: propValue)
116
130
  }
117
131
 
132
+ /// Parses the props whose values are serialized elements, or arrays of them. Other values, such
133
+ /// as a string label, are left to `componentProp(_:)`.
134
+ private static func elementNodes(in props: [String: JSONValue]?) -> [String: VoltraNode] {
135
+ guard let props else { return [:] }
136
+ var nodes: [String: VoltraNode] = [:]
137
+ for (key, value) in props where isElementShaped(value) {
138
+ let name = ShortNames.expand(key)
139
+ guard name != "style", name != "modifiers" else { continue }
140
+ let node = VoltraNode(from: value)
141
+ if !node.isEmpty {
142
+ nodes[name] = node
143
+ }
144
+ }
145
+ return nodes
146
+ }
147
+
148
+ private static func isElementShaped(_ value: JSONValue) -> Bool {
149
+ switch value {
150
+ case let .object(dict):
151
+ return dict["t"]?.intValue != nil
152
+ case let .array(items):
153
+ return !items.isEmpty && items.allSatisfy(isElementShaped)
154
+ default:
155
+ return false
156
+ }
157
+ }
158
+
118
159
  /// Decode parameters from props
119
160
  public func parameters<T: Decodable>(_: T.Type) -> T {
120
161
  guard let props = props else {
@@ -0,0 +1,185 @@
1
+ import Foundation
2
+
3
+ /// The locale and formatting fields of `WidgetEnvironment` (ADR 0009 §1), captured once per render
4
+ /// and shared by the Dynamic Widget and Dynamic Live Activity env builders so both hand JS the same
5
+ /// shape.
6
+ ///
7
+ /// Foundation-only on purpose: the SwiftUI views pass `\.layoutDirection` in as a plain `Bool`, so
8
+ /// the mapping stays testable without a simulator.
9
+ public struct VoltraLocaleEnvironment: Hashable {
10
+ /// BCP-47 tag with Unicode extensions (`pl-PL`, `en-US-u-hc-h23`). Never the ICU `pl_PL` form,
11
+ /// which `Intl` rejects with a `RangeError`.
12
+ public let locale: String
13
+ /// `Locale.preferredLanguages`: the user's list, independent of the extension bundle.
14
+ public let preferredLanguages: [String]
15
+ /// The tag the app stored with `setDynamicWidgetLocale`, if any.
16
+ public let appLocale: String?
17
+ /// `"ltr"` or `"rtl"`.
18
+ public let layoutDirection: String
19
+ /// `"h12"` or `"h23"`.
20
+ public let hourCycle: String
21
+ public let timeZone: String
22
+ /// `"metric"`, `"us"` or `"uk"`.
23
+ public let measurementSystem: String?
24
+ /// Unicode calendar identifier as `Intl` spells it (`gregory`, not `gregorian`).
25
+ public let calendar: String?
26
+ /// 1 = Sunday … 7 = Saturday.
27
+ public let firstDayOfWeek: Int?
28
+
29
+ public init(
30
+ locale: String,
31
+ preferredLanguages: [String],
32
+ appLocale: String?,
33
+ layoutDirection: String,
34
+ hourCycle: String,
35
+ timeZone: String,
36
+ measurementSystem: String?,
37
+ calendar: String?,
38
+ firstDayOfWeek: Int?
39
+ ) {
40
+ self.locale = locale
41
+ self.preferredLanguages = preferredLanguages
42
+ self.appLocale = appLocale
43
+ self.layoutDirection = layoutDirection
44
+ self.hourCycle = hourCycle
45
+ self.timeZone = timeZone
46
+ self.measurementSystem = measurementSystem
47
+ self.calendar = calendar
48
+ self.firstDayOfWeek = firstDayOfWeek
49
+ }
50
+
51
+ /// Captures the environment for `locale`, the SwiftUI `\.locale` of the view being drawn.
52
+ ///
53
+ /// - Parameters:
54
+ /// - isRightToLeft: SwiftUI's `\.layoutDirection == .rightToLeft`; `nil` outside a view body
55
+ /// (the server-update trial render), in which case the locale's script direction is used.
56
+ /// - appLocale: the app's override from the App Group, read by the caller.
57
+ public static func capture(
58
+ locale: Locale,
59
+ isRightToLeft: Bool? = nil,
60
+ appLocale: String? = nil,
61
+ preferredLanguages: [String] = Locale.preferredLanguages,
62
+ timeZone: TimeZone = .current
63
+ ) -> VoltraLocaleEnvironment {
64
+ let rtl = isRightToLeft ?? (locale.language.characterDirection == .rightToLeft)
65
+ return VoltraLocaleEnvironment(
66
+ locale: bcp47(locale),
67
+ preferredLanguages: preferredLanguages,
68
+ appLocale: appLocale.flatMap { $0.isEmpty ? nil : $0 },
69
+ layoutDirection: rtl ? "rtl" : "ltr",
70
+ hourCycle: hourCycle(locale.hourCycle),
71
+ timeZone: timeZone.identifier,
72
+ measurementSystem: measurementSystem(locale.measurementSystem),
73
+ calendar: calendarIdentifier((locale as NSLocale).calendarIdentifier),
74
+ firstDayOfWeek: firstDayOfWeek(locale)
75
+ )
76
+ }
77
+
78
+ // MARK: - Mapping
79
+
80
+ public static func bcp47(_ locale: Locale) -> String {
81
+ locale.identifier(.bcp47)
82
+ }
83
+
84
+ /// `Locale.hourCycle` already reflects the user's 24-Hour Time override on the current locale.
85
+ static func hourCycle(_ cycle: Locale.HourCycle) -> String {
86
+ switch cycle {
87
+ case .zeroToEleven, .oneToTwelve:
88
+ return "h12"
89
+ default:
90
+ return "h23"
91
+ }
92
+ }
93
+
94
+ static func measurementSystem(_ system: Locale.MeasurementSystem) -> String {
95
+ if system == .us {
96
+ return "us"
97
+ }
98
+ if system == .uk {
99
+ return "uk"
100
+ }
101
+ return "metric"
102
+ }
103
+
104
+ /// Foundation spells CLDR calendar types the long way; `Intl` and BCP-47 use the short keys.
105
+ static func calendarIdentifier(_ foundationIdentifier: String) -> String? {
106
+ if foundationIdentifier.isEmpty {
107
+ return nil
108
+ }
109
+ switch foundationIdentifier {
110
+ case "gregorian":
111
+ return "gregory"
112
+ case "ethiopic-amete-alem":
113
+ return "ethioaa"
114
+ default:
115
+ return foundationIdentifier
116
+ }
117
+ }
118
+
119
+ static func firstDayOfWeek(_ locale: Locale) -> Int? {
120
+ var calendar = Calendar(identifier: .gregorian)
121
+ calendar.locale = locale
122
+ let weekday = calendar.firstWeekday
123
+ return (1 ... 7).contains(weekday) ? weekday : nil
124
+ }
125
+
126
+ // MARK: - JSON
127
+
128
+ /// The fields as `"key": value` pairs, one per line, for splicing into a hand-rolled env JSON
129
+ /// object. Absent optionals are left out so they read as `undefined` in JS.
130
+ public var jsonFields: String {
131
+ var fields: [String] = [
132
+ "\"locale\": \(Self.jsonString(locale))",
133
+ "\"preferredLanguages\": [\(preferredLanguages.map(Self.jsonString).joined(separator: ", "))]",
134
+ ]
135
+ if let appLocale {
136
+ fields.append("\"appLocale\": \(Self.jsonString(appLocale))")
137
+ }
138
+ fields.append("\"layoutDirection\": \(Self.jsonString(layoutDirection))")
139
+ fields.append("\"hourCycle\": \(Self.jsonString(hourCycle))")
140
+ fields.append("\"timeZone\": \(Self.jsonString(timeZone))")
141
+ if let measurementSystem {
142
+ fields.append("\"measurementSystem\": \(Self.jsonString(measurementSystem))")
143
+ }
144
+ if let calendar {
145
+ fields.append("\"calendar\": \(Self.jsonString(calendar))")
146
+ }
147
+ if let firstDayOfWeek {
148
+ fields.append("\"firstDayOfWeek\": \(firstDayOfWeek)")
149
+ }
150
+ return fields.joined(separator: ",\n ")
151
+ }
152
+
153
+ /// The same fields for `JSONSerialization`-based builders.
154
+ public var dictionary: [String: Any] {
155
+ var values: [String: Any] = [
156
+ "locale": locale,
157
+ "preferredLanguages": preferredLanguages,
158
+ "layoutDirection": layoutDirection,
159
+ "hourCycle": hourCycle,
160
+ "timeZone": timeZone,
161
+ ]
162
+ if let appLocale {
163
+ values["appLocale"] = appLocale
164
+ }
165
+ if let measurementSystem {
166
+ values["measurementSystem"] = measurementSystem
167
+ }
168
+ if let calendar {
169
+ values["calendar"] = calendar
170
+ }
171
+ if let firstDayOfWeek {
172
+ values["firstDayOfWeek"] = firstDayOfWeek
173
+ }
174
+ return values
175
+ }
176
+
177
+ static func jsonString(_ value: String) -> String {
178
+ let escaped = value
179
+ .replacingOccurrences(of: "\\", with: "\\\\")
180
+ .replacingOccurrences(of: "\"", with: "\\\"")
181
+ .replacingOccurrences(of: "\n", with: "\\n")
182
+ .replacingOccurrences(of: "\r", with: "\\r")
183
+ return "\"\(escaped)\""
184
+ }
185
+ }
@@ -0,0 +1,33 @@
1
+ import Foundation
2
+ import os
3
+
4
+ /// One entry of a component's `modifiers` prop: `{ "$type": "<name>", ...params }` (ADR 0005).
5
+ ///
6
+ /// Decoding lives next to `VoltraElement`, which decodes the prop once while parsing; building
7
+ /// the SwiftUI modifier is the job of `VoltraModifierRegistry` in `ui/Modifiers`.
8
+ public struct VoltraModifierDescriptor {
9
+ public let type: String
10
+ public let params: [String: Any]
11
+
12
+ public init(type: String, params: [String: Any]) {
13
+ self.type = type
14
+ self.params = params
15
+ }
16
+
17
+ private static let logger = Logger(subsystem: "com.voltra", category: "modifier")
18
+
19
+ /// Decodes the JSON-encoded `modifiers` prop. Entries without a string `$type` are dropped.
20
+ public static func parseList(_ json: String?) -> [VoltraModifierDescriptor] {
21
+ guard let json, let data = json.data(using: .utf8) else { return [] }
22
+ guard let entries = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
23
+ logger.warning("Ignoring modifiers that are not a JSON array of objects")
24
+ return []
25
+ }
26
+ return entries.compactMap { entry in
27
+ guard let type = entry["$type"] as? String else { return nil }
28
+ var params = entry
29
+ params.removeValue(forKey: "$type")
30
+ return VoltraModifierDescriptor(type: type, params: params)
31
+ }
32
+ }
33
+ }
@@ -43,6 +43,17 @@ public enum VoltraWidgetDefaults {
43
43
  try? resolvedDefaults().string(forKey: VoltraStorageKeys.devServerURL)
44
44
  }
45
45
 
46
+ /// The app's language override for Dynamic Widgets and Dynamic Live Activities
47
+ /// (`env.appLocale`). nil when none is set or the app group is not configured.
48
+ public static func dynamicWidgetLocale() -> String? {
49
+ guard let tag = try? resolvedDefaults().string(forKey: VoltraStorageKeys.dynamicWidgetLocale),
50
+ !tag.isEmpty
51
+ else {
52
+ return nil
53
+ }
54
+ return tag
55
+ }
56
+
46
57
  // MARK: - Write
47
58
 
48
59
  public static func setWidgetJson(_ json: String, for widgetId: String, deepLinkUrl: String?) throws {
@@ -84,6 +95,19 @@ public enum VoltraWidgetDefaults {
84
95
  defaults.synchronize()
85
96
  }
86
97
 
98
+ /// Stores (or, with nil, clears) the app's language override for Dynamic Widgets. Throws
99
+ /// `WidgetError.appGroupNotConfigured` without a `groupIdentifier`, since the extension could
100
+ /// never read the value.
101
+ public static func setDynamicWidgetLocale(_ tag: String?) throws {
102
+ let defaults = try resolvedDefaults()
103
+ if let tag, !tag.isEmpty {
104
+ defaults.set(tag, forKey: VoltraStorageKeys.dynamicWidgetLocale)
105
+ } else {
106
+ defaults.removeObject(forKey: VoltraStorageKeys.dynamicWidgetLocale)
107
+ }
108
+ defaults.synchronize()
109
+ }
110
+
87
111
  // MARK: - Remove
88
112
 
89
113
  /// Removes all persisted data (json, deepLinkUrl, timeline, Dynamic Widget props) for a single widget.