@use-voltra/ios-client 2.3.2 → 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 (114) 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 -0
  4. package/build/commonjs/index.js.map +1 -1
  5. package/build/commonjs/native/NativeVoltra.js.map +1 -1
  6. package/build/module/dynamic-widget/api.js +26 -0
  7. package/build/module/dynamic-widget/api.js.map +1 -1
  8. package/build/module/index.js +1 -1
  9. package/build/module/index.js.map +1 -1
  10. package/build/module/native/NativeVoltra.js.map +1 -1
  11. package/build/typescript/commonjs/dynamic-widget/api.d.ts +12 -0
  12. package/build/typescript/commonjs/dynamic-widget/api.d.ts.map +1 -1
  13. package/build/typescript/commonjs/index.d.ts +1 -1
  14. package/build/typescript/commonjs/index.d.ts.map +1 -1
  15. package/build/typescript/commonjs/native/NativeVoltra.d.ts +2 -0
  16. package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
  17. package/build/typescript/module/dynamic-widget/api.d.ts +12 -0
  18. package/build/typescript/module/dynamic-widget/api.d.ts.map +1 -1
  19. package/build/typescript/module/index.d.ts +1 -1
  20. package/build/typescript/module/index.d.ts.map +1 -1
  21. package/build/typescript/module/native/NativeVoltra.d.ts +2 -0
  22. package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
  23. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +4 -0
  24. package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -1
  25. package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js +9 -1
  26. package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js.map +1 -1
  27. package/expo-plugin/build/cjs/ios-widget/files/index.js +8 -1
  28. package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
  29. package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js +16 -4
  30. package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js.map +1 -1
  31. package/expo-plugin/build/cjs/ios-widget/files/localization.js +167 -0
  32. package/expo-plugin/build/cjs/ios-widget/files/localization.js.map +1 -0
  33. package/expo-plugin/build/cjs/ios-widget/files/swift.js +68 -7
  34. package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
  35. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js +4 -1
  36. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js.map +1 -1
  37. package/expo-plugin/build/cjs/utils/fileDiscovery.js +7 -4
  38. package/expo-plugin/build/cjs/utils/fileDiscovery.js.map +1 -1
  39. package/expo-plugin/build/cjs/validation.js +1 -0
  40. package/expo-plugin/build/cjs/validation.js.map +1 -1
  41. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +4 -0
  42. package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -1
  43. package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js +9 -1
  44. package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js.map +1 -1
  45. package/expo-plugin/build/esm/ios-widget/files/index.js +8 -1
  46. package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
  47. package/expo-plugin/build/esm/ios-widget/files/infoPlist.js +16 -4
  48. package/expo-plugin/build/esm/ios-widget/files/infoPlist.js.map +1 -1
  49. package/expo-plugin/build/esm/ios-widget/files/localization.js +128 -0
  50. package/expo-plugin/build/esm/ios-widget/files/localization.js.map +1 -0
  51. package/expo-plugin/build/esm/ios-widget/files/swift.js +69 -8
  52. package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
  53. package/expo-plugin/build/esm/ios-widget/xcode/groups.js +5 -2
  54. package/expo-plugin/build/esm/ios-widget/xcode/groups.js.map +1 -1
  55. package/expo-plugin/build/esm/utils/fileDiscovery.js +6 -3
  56. package/expo-plugin/build/esm/utils/fileDiscovery.js.map +1 -1
  57. package/expo-plugin/build/esm/validation.js +2 -1
  58. package/expo-plugin/build/esm/validation.js.map +1 -1
  59. package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -1
  60. package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts.map +1 -1
  61. package/expo-plugin/build/types/ios-widget/files/index.d.ts +1 -0
  62. package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
  63. package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts +3 -1
  64. package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts.map +1 -1
  65. package/expo-plugin/build/types/ios-widget/files/localization.d.ts +46 -0
  66. package/expo-plugin/build/types/ios-widget/files/localization.d.ts.map +1 -0
  67. package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
  68. package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts.map +1 -1
  69. package/expo-plugin/build/types/types.d.ts +13 -4
  70. package/expo-plugin/build/types/types.d.ts.map +1 -1
  71. package/expo-plugin/build/types/utils/fileDiscovery.d.ts +2 -0
  72. package/expo-plugin/build/types/utils/fileDiscovery.d.ts.map +1 -1
  73. package/expo-plugin/build/types/validation.d.ts.map +1 -1
  74. package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +4 -0
  75. package/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts +9 -1
  76. package/expo-plugin/src/ios-widget/files/index.ts +14 -1
  77. package/expo-plugin/src/ios-widget/files/infoPlist.ts +23 -4
  78. package/expo-plugin/src/ios-widget/files/localization.ts +176 -0
  79. package/expo-plugin/src/ios-widget/files/swift.ts +88 -10
  80. package/expo-plugin/src/ios-widget/xcode/groups.ts +5 -2
  81. package/expo-plugin/src/types.ts +13 -3
  82. package/expo-plugin/src/utils/fileDiscovery.ts +7 -3
  83. package/expo-plugin/src/validation.ts +2 -0
  84. package/ios/Package.swift +8 -1
  85. package/ios/VoltraWidget.podspec +1 -1
  86. package/ios/app/NativeVoltra.mm +15 -2
  87. package/ios/app/VoltraModule.swift +11 -0
  88. package/ios/app/VoltraModuleImpl.swift +14 -0
  89. package/ios/app/VoltraViewComponentView.mm +2 -2
  90. package/ios/app/VoltraViewRoot.swift +6 -12
  91. package/ios/shared/ShortNames.swift +1 -0
  92. package/ios/shared/VoltraConstants.swift +4 -0
  93. package/ios/shared/VoltraElement.swift +43 -2
  94. package/ios/shared/VoltraLocaleEnvironment.swift +185 -0
  95. package/ios/shared/VoltraModifierDescriptor.swift +33 -0
  96. package/ios/shared/VoltraWidgetDefaults.swift +24 -0
  97. package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +50 -28
  98. package/ios/target/VoltraClientWidgetRuntime.swift +12 -1
  99. package/ios/target/VoltraHomeWidget.swift +10 -15
  100. package/ios/target/VoltraWidget.swift +26 -18
  101. package/ios/ui/Extensions/VoltraNode+View.swift +7 -0
  102. package/ios/ui/Helpers/VoltraDeepLinkResolver+URL.swift +37 -0
  103. package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +1 -32
  104. package/ios/ui/Modifiers/BuiltInModifiers.swift +613 -0
  105. package/ios/ui/Modifiers/VoltraElement+nativeModifiers.swift +53 -0
  106. package/ios/ui/Modifiers/VoltraModifierRegistry.swift +181 -0
  107. package/ios/ui/Modifiers/VoltraRootDefaults.swift +100 -0
  108. package/ios/ui/Style/TextStyle.swift +4 -2
  109. package/ios/ui/Style/View+applyStyle.swift +1 -1
  110. package/ios/ui/Views/VoltraText.swift +4 -8
  111. package/package.json +4 -4
  112. package/src/dynamic-widget/api.ts +28 -0
  113. package/src/index.ts +6 -1
  114. package/src/native/NativeVoltra.ts +2 -0
@@ -21,7 +21,10 @@ public enum VoltraDynamicLiveActivityRenderer {
21
21
  definitionId: String,
22
22
  context: ActivityViewContext<Attributes>
23
23
  ) -> DynamicIsland {
24
- DynamicIsland {
24
+ // Set once on the Dynamic Island, which makes it the default for every region; the regions
25
+ // only drop the tree's own widgetURL so the configured link takes precedence.
26
+ let deepLinkURL = context.attributes.deepLinkUrl.flatMap(VoltraDeepLinkResolver.resolveUrl)
27
+ let dynamicIsland = DynamicIsland {
25
28
  DynamicIslandExpandedRegion(.leading) {
26
29
  VoltraDynamicLiveActivityDynamicIslandRegionView(
27
30
  definitionId: definitionId,
@@ -73,6 +76,7 @@ public enum VoltraDynamicLiveActivityRenderer {
73
76
  // DynamicIsland itself is not a View and cannot read SwiftUI Environment.
74
77
  // Do not execute a definition against fabricated values solely to obtain a
75
78
  // keyline tint; its region views below read the actual environment.
79
+ return deepLinkURL.map { dynamicIsland.widgetURL($0) } ?? dynamicIsland
76
80
  }
77
81
 
78
82
  fileprivate static func resolve<Attributes: VoltraDynamicLiveActivityDefinition>(
@@ -81,6 +85,7 @@ public enum VoltraDynamicLiveActivityRenderer {
81
85
  activityFamily: String?,
82
86
  colorScheme: ColorScheme? = nil,
83
87
  locale: Locale = .current,
88
+ layoutDirection: LayoutDirection? = nil,
84
89
  widgetRenderingMode: WidgetRenderingMode = .fullColor
85
90
  ) -> VoltraDynamicLiveActivityResolvedContent {
86
91
  guard VoltraDynamicLiveActivityRegistry.shared.contains(definitionId) else {
@@ -91,6 +96,13 @@ public enum VoltraDynamicLiveActivityRenderer {
91
96
  logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Could not encode content-state props")
92
97
  return .empty
93
98
  }
99
+ // Captured once so every region shares it and the cache key covers every locale field,
100
+ // including the app's `setDynamicWidgetLocale` override (ADR 0009).
101
+ let localeEnvironment = VoltraLocaleEnvironment.capture(
102
+ locale: locale,
103
+ isRightToLeft: layoutDirection.map { $0 == .rightToLeft },
104
+ appLocale: VoltraWidgetDefaults.dynamicWidgetLocale()
105
+ )
94
106
  // WidgetKit asks each Dynamic Island region (and each presentation) to render
95
107
  // independently, all with identical state/environment. Cache the resolved
96
108
  // variants shape so those siblings share one bundle load, one JS evaluation,
@@ -103,7 +115,7 @@ public enum VoltraDynamicLiveActivityRenderer {
103
115
  props: propsJSON,
104
116
  activityFamily: activityFamily,
105
117
  colorScheme: String(describing: colorScheme ?? .light),
106
- locale: locale.identifier,
118
+ locale: localeEnvironment,
107
119
  widgetRenderingMode: String(describing: widgetRenderingMode),
108
120
  isStale: context.isStale
109
121
  )
@@ -114,7 +126,7 @@ public enum VoltraDynamicLiveActivityRenderer {
114
126
  propsJSON: propsJSON,
115
127
  activityFamily: activityFamily,
116
128
  colorScheme: colorScheme,
117
- locale: locale,
129
+ localeEnvironment: localeEnvironment,
118
130
  widgetRenderingMode: widgetRenderingMode
119
131
  )
120
132
  }
@@ -126,13 +138,13 @@ public enum VoltraDynamicLiveActivityRenderer {
126
138
  propsJSON: String,
127
139
  activityFamily: String?,
128
140
  colorScheme: ColorScheme?,
129
- locale: Locale,
141
+ localeEnvironment: VoltraLocaleEnvironment,
130
142
  widgetRenderingMode: WidgetRenderingMode
131
143
  ) -> VoltraDynamicLiveActivityResolvedContent {
132
144
  let environmentJSON = VoltraDynamicLiveActivityEnvironmentBuilder.build(
133
145
  date: Date(),
134
146
  colorScheme: colorScheme,
135
- locale: locale,
147
+ localeEnvironment: localeEnvironment,
136
148
  widgetRenderingMode: widgetRenderingMode,
137
149
  isStale: context.isStale,
138
150
  activityFamily: activityFamily
@@ -176,18 +188,23 @@ private struct VoltraDynamicLiveActivityDynamicIslandRegionView<Attributes: Volt
176
188
 
177
189
  @Environment(\.colorScheme) private var colorScheme
178
190
  @Environment(\.locale) private var locale
191
+ @Environment(\.layoutDirection) private var layoutDirection
179
192
  @Environment(\.widgetRenderingMode) private var widgetRenderingMode
180
193
 
181
194
  var body: some View {
182
- VoltraDynamicLiveActivityRenderer.resolve(
195
+ let deepLinkURL = context.attributes.deepLinkUrl.flatMap(VoltraDeepLinkResolver.resolveUrl)
196
+ let content = VoltraDynamicLiveActivityRenderer.resolve(
183
197
  definitionId: definitionId,
184
198
  context: context,
185
199
  activityFamily: nil,
186
200
  colorScheme: colorScheme,
187
201
  locale: locale,
202
+ layoutDirection: layoutDirection,
188
203
  widgetRenderingMode: widgetRenderingMode
189
204
  )
190
- .view(for: region, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl)
205
+ let _ = VoltraRootModifiers.warnIfIslandIgnoresWidgetURL(in: region, root: content.root(for: region), configured: deepLinkURL)
206
+ content.view(for: region, activityId: context.activityID)
207
+ .voltraDeferringWidgetURL(to: deepLinkURL)
191
208
  }
192
209
  }
193
210
 
@@ -197,6 +214,7 @@ private struct VoltraDynamicLiveActivityLockScreenView<Attributes: VoltraDynamic
197
214
 
198
215
  @Environment(\.colorScheme) private var colorScheme
199
216
  @Environment(\.locale) private var locale
217
+ @Environment(\.layoutDirection) private var layoutDirection
200
218
  @Environment(\.widgetRenderingMode) private var widgetRenderingMode
201
219
 
202
220
  var body: some View {
@@ -206,14 +224,12 @@ private struct VoltraDynamicLiveActivityLockScreenView<Attributes: VoltraDynamic
206
224
  activityFamily: nil,
207
225
  colorScheme: colorScheme,
208
226
  locale: locale,
227
+ layoutDirection: layoutDirection,
209
228
  widgetRenderingMode: widgetRenderingMode
210
229
  )
211
- if let tint = content.payload?.activityBackgroundTint, let color = JSColorParser.parse(tint) {
212
- content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl)
213
- .activityBackgroundTint(color)
214
- } else {
215
- content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl)
216
- }
230
+ content.view(for: .lockScreen, activityId: context.activityID)
231
+ .voltraWidgetURL(configured: context.attributes.deepLinkUrl.flatMap(VoltraDeepLinkResolver.resolveUrl))
232
+ .voltraActivityBackgroundTint(configured: content.payload?.activityBackgroundTint.flatMap { JSColorParser.parse($0) })
217
233
  }
218
234
  }
219
235
 
@@ -225,6 +241,7 @@ private struct VoltraDynamicLiveActivityAdaptiveLockScreenView<Attributes: Voltr
225
241
  @Environment(\.activityFamily) private var activityFamily
226
242
  @Environment(\.colorScheme) private var colorScheme
227
243
  @Environment(\.locale) private var locale
244
+ @Environment(\.layoutDirection) private var layoutDirection
228
245
  @Environment(\.widgetRenderingMode) private var widgetRenderingMode
229
246
 
230
247
  var body: some View {
@@ -234,12 +251,14 @@ private struct VoltraDynamicLiveActivityAdaptiveLockScreenView<Attributes: Voltr
234
251
  activityFamily: String(describing: activityFamily),
235
252
  colorScheme: colorScheme,
236
253
  locale: locale,
254
+ layoutDirection: layoutDirection,
237
255
  widgetRenderingMode: widgetRenderingMode
238
256
  )
257
+ // One widgetURL for the whole presentation, including the small family that shows two
258
+ // regions side by side.
239
259
  familyContent(content: content)
240
- .voltraIfLet(content.payload?.activityBackgroundTint.flatMap { JSColorParser.parse($0) }) { view, color in
241
- view.activityBackgroundTint(color)
242
- }
260
+ .voltraWidgetURL(configured: context.attributes.deepLinkUrl.flatMap(VoltraDeepLinkResolver.resolveUrl))
261
+ .voltraActivityBackgroundTint(configured: content.payload?.activityBackgroundTint.flatMap { JSColorParser.parse($0) })
243
262
  }
244
263
 
245
264
  @ViewBuilder
@@ -247,7 +266,7 @@ private struct VoltraDynamicLiveActivityAdaptiveLockScreenView<Attributes: Voltr
247
266
  if activityFamily == .small {
248
267
  smallFamilyContent(content: content)
249
268
  } else {
250
- content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl)
269
+ content.view(for: .lockScreen, activityId: context.activityID)
251
270
  }
252
271
  }
253
272
 
@@ -256,14 +275,13 @@ private struct VoltraDynamicLiveActivityAdaptiveLockScreenView<Attributes: Voltr
256
275
  if content.hasContent(for: .supplementalActivityFamiliesSmall) {
257
276
  content.view(
258
277
  for: .supplementalActivityFamiliesSmall,
259
- activityId: context.activityID,
260
- deepLink: context.attributes.deepLinkUrl
278
+ activityId: context.activityID
261
279
  )
262
280
  } else if content.hasContent(for: .islandCompactLeading) || content.hasContent(for: .islandCompactTrailing) {
263
281
  HStack(spacing: 0) {
264
- content.view(for: .islandCompactLeading, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl)
282
+ content.view(for: .islandCompactLeading, activityId: context.activityID)
265
283
  Spacer()
266
- content.view(for: .islandCompactTrailing, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl)
284
+ content.view(for: .islandCompactTrailing, activityId: context.activityID)
267
285
  }
268
286
  .frame(maxWidth: .infinity)
269
287
  }
@@ -279,12 +297,15 @@ private struct VoltraDynamicLiveActivityResolvedContent {
279
297
  !(payload?.regions[region] ?? []).isEmpty
280
298
  }
281
299
 
300
+ func root(for region: VoltraRegion) -> VoltraNode? {
301
+ guard let nodes = payload?.regions[region], !nodes.isEmpty else { return nil }
302
+ return nodes.count == 1 ? nodes[0] : .array(nodes)
303
+ }
304
+
282
305
  @ViewBuilder
283
- func view(for region: VoltraRegion, activityId: String, deepLink: String?) -> some View {
284
- if let nodes = payload?.regions[region], !nodes.isEmpty {
285
- let root: VoltraNode = nodes.count == 1 ? nodes[0] : .array(nodes)
306
+ func view(for region: VoltraRegion, activityId: String) -> some View {
307
+ if let root = root(for: region) {
286
308
  Voltra(root: root, activityId: activityId)
287
- .voltraIfLet(deepLink.flatMap(VoltraDeepLinkResolver.resolveUrl)) { view, url in view.widgetURL(url) }
288
309
  }
289
310
  }
290
311
  }
@@ -302,7 +323,7 @@ private final class VoltraDynamicLiveActivityRenderCache: @unchecked Sendable {
302
323
  let props: String
303
324
  let activityFamily: String?
304
325
  let colorScheme: String
305
- let locale: String
326
+ let locale: VoltraLocaleEnvironment
306
327
  let widgetRenderingMode: String
307
328
  let isStale: Bool
308
329
  }
@@ -330,7 +351,7 @@ private enum VoltraDynamicLiveActivityEnvironmentBuilder {
330
351
  static func build(
331
352
  date: Date,
332
353
  colorScheme: ColorScheme?,
333
- locale: Locale,
354
+ localeEnvironment: VoltraLocaleEnvironment,
334
355
  widgetRenderingMode: WidgetRenderingMode,
335
356
  isStale: Bool,
336
357
  activityFamily: String?
@@ -351,11 +372,12 @@ private enum VoltraDynamicLiveActivityEnvironmentBuilder {
351
372
  var environment: [String: Any] = [
352
373
  "date": Int(date.timeIntervalSince1970 * 1000),
353
374
  "colorScheme": String(describing: colorScheme ?? .light),
354
- "locale": locale.identifier,
355
375
  "widgetRenderingMode": String(describing: widgetRenderingMode),
356
376
  "build": build,
357
377
  "isStale": isStale,
358
378
  ]
379
+ // BCP-47 `locale` and the rest of the ADR 0009 locale fields.
380
+ environment.merge(localeEnvironment.dictionary) { current, _ in current }
359
381
  if let activityFamily {
360
382
  environment["activityFamily"] = activityFamily
361
383
  }
@@ -255,6 +255,7 @@ public enum VoltraClientWidgetEnvBuilder {
255
255
  widgetRenderingMode: WidgetRenderingMode,
256
256
  showsWidgetContainerBackground: Bool,
257
257
  locale: Locale,
258
+ layoutDirection: LayoutDirection? = nil,
258
259
  configuration: [String: String],
259
260
  serverUpdateJSON: String? = nil
260
261
  ) -> String {
@@ -299,12 +300,20 @@ public enum VoltraClientWidgetEnvBuilder {
299
300
  // sends and what WidgetScope.of(widgetId, configuration:) resolves to.
300
301
  let instanceEntry = WidgetCanonicalConfiguration.key(configuration).map { ",\n \"instance\": \(jsonString($0))" } ?? ""
301
302
 
303
+ // Locale fields (ADR 0009 §1). `locale` is BCP-47 (`pl-PL`), never the ICU `pl_PL` that
304
+ // `Locale.identifier` gives and `Intl` rejects.
305
+ let localeEnvironment = VoltraLocaleEnvironment.capture(
306
+ locale: locale,
307
+ isRightToLeft: layoutDirection.map { $0 == .rightToLeft },
308
+ appLocale: VoltraWidgetDefaults.dynamicWidgetLocale()
309
+ )
310
+
302
311
  return """
303
312
  {
304
313
  "date": \(timestampMs),
305
314
  "widgetFamily": \(jsonString(familyString(widgetFamily))),
306
315
  "colorScheme": \(jsonString(schemeString(colorScheme))),
307
- "locale": \(jsonString(locale.identifier)),
316
+ \(localeEnvironment.jsonFields),
308
317
  "widgetRenderingMode": \(jsonString(renderingModeString(widgetRenderingMode))),
309
318
  "showsWidgetContainerBackground": \(showsWidgetContainerBackground),
310
319
  "configuration": \(configurationJSON),
@@ -355,6 +364,7 @@ public struct VoltraClientWidgetContentView: View {
355
364
  @Environment(\.widgetRenderingMode) private var widgetRenderingMode
356
365
  @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground
357
366
  @Environment(\.locale) private var locale
367
+ @Environment(\.layoutDirection) private var layoutDirection
358
368
 
359
369
  public init(entry: VoltraClientWidgetEntry, initialState: Data?) {
360
370
  self.entry = entry
@@ -381,6 +391,7 @@ public struct VoltraClientWidgetContentView: View {
381
391
  widgetRenderingMode: widgetRenderingMode,
382
392
  showsWidgetContainerBackground: showsWidgetContainerBackground,
383
393
  locale: locale,
394
+ layoutDirection: layoutDirection,
384
395
  configuration: entry.configuration,
385
396
  serverUpdateJSON: entry.serverUpdateJSON
386
397
  )
@@ -284,6 +284,7 @@ public struct VoltraHomeWidgetView: View {
284
284
 
285
285
  public var body: some View {
286
286
  let mappedRenderingMode = mapWidgetRenderingMode(widgetRenderingMode)
287
+ let rootModifiers = VoltraRootModifiers(root: entry.rootNode)
287
288
 
288
289
  Group {
289
290
  if let root = entry.rootNode {
@@ -298,7 +299,7 @@ public struct VoltraHomeWidgetView: View {
298
299
  )
299
300
  )
300
301
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
301
- .widgetURL(resolveDeepLinkURL(entry))
302
+ .voltraWidgetURL(configured: configuredDeepLinkURL(entry), tree: rootModifiers) { defaultDeepLinkURL(entry) }
302
303
 
303
304
  if showRefreshButton {
304
305
  content.overlay(alignment: .topTrailing) {
@@ -311,7 +312,7 @@ public struct VoltraHomeWidgetView: View {
311
312
  placeholderView(widgetId: entry.widgetId)
312
313
  }
313
314
  }
314
- .disableWidgetMarginsIfAvailable()
315
+ .voltraDefaultContainerBackground(tree: rootModifiers)
315
316
  }
316
317
 
317
318
  private func mapWidgetRenderingMode(_ mode: WidgetRenderingMode) -> VoltraWidgetRenderingMode {
@@ -442,18 +443,8 @@ private func reconstructWithSharedData(content: Any, root: [String: Any]) -> Dat
442
443
 
443
444
  // MARK: - Deep link helpers
444
445
 
445
- private extension View {
446
- @ViewBuilder
447
- func disableWidgetMarginsIfAvailable() -> some View {
448
- if #available(iOSApplicationExtension 17.0, *) {
449
- containerBackground(.clear, for: .widget)
450
- } else {
451
- self
452
- }
453
- }
454
- }
455
-
456
- private func resolveDeepLinkURL(_ entry: VoltraHomeWidgetEntry) -> URL? {
446
+ /// The deep link configured for the widget: the timeline entry's, else the stored static one.
447
+ private func configuredDeepLinkURL(_ entry: VoltraHomeWidgetEntry) -> URL? {
457
448
  // Prefer the timeline entry's deep link URL if available
458
449
  if let entryUrl = entry.deepLinkUrl, !entryUrl.isEmpty {
459
450
  if entryUrl.contains("://"), let url = URL(string: entryUrl) {
@@ -476,7 +467,11 @@ private func resolveDeepLinkURL(_ entry: VoltraHomeWidgetEntry) -> URL? {
476
467
  }
477
468
  }
478
469
 
479
- // Default deep link with widget info
470
+ return nil
471
+ }
472
+
473
+ /// The synthetic deep link used when nothing is configured.
474
+ private func defaultDeepLinkURL(_ entry: VoltraHomeWidgetEntry) -> URL? {
480
475
  guard let scheme = VoltraDeepLinkResolver.deepLinkScheme() else { return nil }
481
476
 
482
477
  var tag = "unknown"
@@ -29,11 +29,8 @@ public struct VoltraWidget: Widget {
29
29
  private func adaptiveConfig() -> some WidgetConfiguration {
30
30
  ActivityConfiguration(for: VoltraAttributes.self) { context in
31
31
  adaptiveLocksScreenView(context: context)
32
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
33
- .voltraIfLet(context.state.activityBackgroundTint) { view, tint in
34
- let color = JSColorParser.parse(tint)
35
- view.activityBackgroundTint(color)
36
- }
32
+ .voltraWidgetURL(configured: VoltraDeepLinkResolver.resolve(context.attributes))
33
+ .voltraActivityBackgroundTint(configured: context.state.activityBackgroundTint.flatMap { JSColorParser.parse($0) })
37
34
  } dynamicIsland: { context in
38
35
  dynamicIslandContent(context: context)
39
36
  }
@@ -45,11 +42,8 @@ public struct VoltraWidget: Widget {
45
42
  private func defaultConfig() -> some WidgetConfiguration {
46
43
  ActivityConfiguration(for: VoltraAttributes.self) { context in
47
44
  Voltra(root: rootNode(for: .lockScreen, from: context.state), activityId: context.activityID)
48
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
49
- .voltraIfLet(context.state.activityBackgroundTint) { view, tint in
50
- let color = JSColorParser.parse(tint)
51
- view.activityBackgroundTint(color)
52
- }
45
+ .voltraWidgetURL(configured: VoltraDeepLinkResolver.resolve(context.attributes))
46
+ .voltraActivityBackgroundTint(configured: context.state.activityBackgroundTint.flatMap { JSColorParser.parse($0) })
53
47
  } dynamicIsland: { context in
54
48
  dynamicIslandContent(context: context)
55
49
  }
@@ -65,32 +59,46 @@ public struct VoltraWidget: Widget {
65
59
  // MARK: - Dynamic Island (shared between iOS versions)
66
60
 
67
61
  private func dynamicIslandContent(context: ActivityViewContext<VoltraAttributes>) -> DynamicIsland {
68
- let dynamicIsland = DynamicIsland {
62
+ // Set once on the Dynamic Island, which makes it the default for every region; the regions
63
+ // only drop the tree's own widgetURL so the configured link takes precedence.
64
+ let deepLinkURL = VoltraDeepLinkResolver.resolve(context.attributes)
65
+ for region in VoltraRootModifiers.islandTapRegions {
66
+ VoltraRootModifiers.warnIfIslandIgnoresWidgetURL(
67
+ in: region,
68
+ root: rootNode(for: region, from: context.state),
69
+ configured: deepLinkURL
70
+ )
71
+ }
72
+ var dynamicIsland = DynamicIsland {
69
73
  DynamicIslandExpandedRegion(.leading) {
70
74
  Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID)
71
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
75
+ .voltraDeferringWidgetURL(to: deepLinkURL)
72
76
  }
73
77
  DynamicIslandExpandedRegion(.trailing) {
74
78
  Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID)
75
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
79
+ .voltraDeferringWidgetURL(to: deepLinkURL)
76
80
  }
77
81
  DynamicIslandExpandedRegion(.center) {
78
82
  Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID)
79
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
83
+ .voltraDeferringWidgetURL(to: deepLinkURL)
80
84
  }
81
85
  DynamicIslandExpandedRegion(.bottom) {
82
86
  Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID)
83
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
87
+ .voltraDeferringWidgetURL(to: deepLinkURL)
84
88
  }
85
89
  } compactLeading: {
86
90
  Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID)
87
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
91
+ .voltraDeferringWidgetURL(to: deepLinkURL)
88
92
  } compactTrailing: {
89
93
  Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID)
90
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
94
+ .voltraDeferringWidgetURL(to: deepLinkURL)
91
95
  } minimal: {
92
96
  Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID)
93
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
97
+ .voltraDeferringWidgetURL(to: deepLinkURL)
98
+ }
99
+
100
+ if let deepLinkURL {
101
+ dynamicIsland = dynamicIsland.widgetURL(deepLinkURL)
94
102
  }
95
103
 
96
104
  // Apply keylineTint if specified
@@ -37,6 +37,13 @@ struct VoltraElementView: View {
37
37
  let element: VoltraElement
38
38
 
39
39
  var body: some View {
40
+ // Native modifiers wrap the finished, styled component (ADR 0005).
41
+ component
42
+ .applyNativeModifiers(element.nativeModifiers)
43
+ }
44
+
45
+ @ViewBuilder
46
+ private var component: some View {
40
47
  switch element.type {
41
48
  case "Button":
42
49
  VoltraButton(element)
@@ -0,0 +1,37 @@
1
+ import Foundation
2
+
3
+ /// Turns the deep link strings Voltra accepts into URLs. The ActivityKit-specific entry point is
4
+ /// in `VoltraDeepLinkResolver.swift`; this half only needs Foundation, so native modifiers such as
5
+ /// `widgetURL` resolve links the same way.
6
+ public enum VoltraDeepLinkResolver {
7
+ static func deepLinkScheme() -> String? {
8
+ if let types = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] {
9
+ for t in types {
10
+ if let schemes = t["CFBundleURLSchemes"] as? [String], let s = schemes.first, !s.isEmpty {
11
+ return s
12
+ }
13
+ }
14
+ }
15
+ return Bundle.main.bundleIdentifier
16
+ }
17
+
18
+ /// Resolves a URL string, supporting both absolute and relative paths
19
+ /// - Parameter raw: The URL string (e.g., "myapp://path", "/path", or "path")
20
+ /// - Returns: A resolved URL, or nil if invalid
21
+ public static func resolveUrl(_ raw: String) -> URL? {
22
+ guard !raw.isEmpty else { return nil }
23
+
24
+ // If it's already an absolute URL, use it as-is
25
+ if raw.contains("://"), let url = URL(string: raw) {
26
+ return url
27
+ }
28
+
29
+ // Otherwise, prepend the app's URL scheme
30
+ if let scheme = deepLinkScheme() {
31
+ let path = raw.hasPrefix("/") ? raw : "/\(raw)"
32
+ return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))")
33
+ }
34
+
35
+ return nil
36
+ }
37
+ }
@@ -2,18 +2,7 @@ import ActivityKit
2
2
  import Foundation
3
3
  import SwiftUI
4
4
 
5
- public enum VoltraDeepLinkResolver {
6
- static func deepLinkScheme() -> String? {
7
- if let types = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] {
8
- for t in types {
9
- if let schemes = t["CFBundleURLSchemes"] as? [String], let s = schemes.first, !s.isEmpty {
10
- return s
11
- }
12
- }
13
- }
14
- return Bundle.main.bundleIdentifier
15
- }
16
-
5
+ extension VoltraDeepLinkResolver {
17
6
  static func resolve(
18
7
  _ attributes: VoltraAttributes
19
8
  ) -> URL? {
@@ -22,24 +11,4 @@ public enum VoltraDeepLinkResolver {
22
11
  }
23
12
  return nil
24
13
  }
25
-
26
- /// Resolves a URL string, supporting both absolute and relative paths
27
- /// - Parameter raw: The URL string (e.g., "myapp://path", "/path", or "path")
28
- /// - Returns: A resolved URL, or nil if invalid
29
- public static func resolveUrl(_ raw: String) -> URL? {
30
- guard !raw.isEmpty else { return nil }
31
-
32
- // If it's already an absolute URL, use it as-is
33
- if raw.contains("://"), let url = URL(string: raw) {
34
- return url
35
- }
36
-
37
- // Otherwise, prepend the app's URL scheme
38
- if let scheme = deepLinkScheme() {
39
- let path = raw.hasPrefix("/") ? raw : "/\(raw)"
40
- return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))")
41
- }
42
-
43
- return nil
44
- }
45
14
  }