@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.
- package/build/commonjs/dynamic-widget/api.js +28 -1
- package/build/commonjs/dynamic-widget/api.js.map +1 -1
- package/build/commonjs/index.js +7 -12
- package/build/commonjs/index.js.map +1 -1
- package/build/commonjs/native/NativeVoltra.js.map +1 -1
- package/build/commonjs/utils/useUpdateOnHMR.js +6 -14
- package/build/commonjs/utils/useUpdateOnHMR.js.map +1 -1
- package/build/module/dynamic-widget/api.js +26 -0
- package/build/module/dynamic-widget/api.js.map +1 -1
- package/build/module/index.js +1 -2
- package/build/module/index.js.map +1 -1
- package/build/module/native/NativeVoltra.js.map +1 -1
- package/build/module/utils/useUpdateOnHMR.js +1 -13
- package/build/module/utils/useUpdateOnHMR.js.map +1 -1
- package/build/typescript/commonjs/dynamic-widget/api.d.ts +12 -0
- package/build/typescript/commonjs/dynamic-widget/api.d.ts.map +1 -1
- package/build/typescript/commonjs/index.d.ts +1 -2
- package/build/typescript/commonjs/index.d.ts.map +1 -1
- package/build/typescript/commonjs/native/NativeVoltra.d.ts +2 -0
- package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
- package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts +1 -1
- package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts.map +1 -1
- package/build/typescript/module/dynamic-widget/api.d.ts +12 -0
- package/build/typescript/module/dynamic-widget/api.d.ts.map +1 -1
- package/build/typescript/module/index.d.ts +1 -2
- package/build/typescript/module/index.d.ts.map +1 -1
- package/build/typescript/module/native/NativeVoltra.d.ts +2 -0
- package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
- package/build/typescript/module/utils/useUpdateOnHMR.d.ts +1 -1
- package/build/typescript/module/utils/useUpdateOnHMR.d.ts.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +4 -0
- package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js +9 -1
- package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/index.js +8 -1
- package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js +16 -4
- package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/localization.js +167 -0
- package/expo-plugin/build/cjs/ios-widget/files/localization.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/files/swift.js +68 -7
- package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/groups.js +4 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/groups.js.map +1 -1
- package/expo-plugin/build/cjs/utils/fileDiscovery.js +7 -4
- package/expo-plugin/build/cjs/utils/fileDiscovery.js.map +1 -1
- package/expo-plugin/build/cjs/validation.js +1 -0
- package/expo-plugin/build/cjs/validation.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +4 -0
- package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js +9 -1
- package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/index.js +8 -1
- package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/infoPlist.js +16 -4
- package/expo-plugin/build/esm/ios-widget/files/infoPlist.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/localization.js +128 -0
- package/expo-plugin/build/esm/ios-widget/files/localization.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/files/swift.js +69 -8
- package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/groups.js +5 -2
- package/expo-plugin/build/esm/ios-widget/xcode/groups.js.map +1 -1
- package/expo-plugin/build/esm/utils/fileDiscovery.js +6 -3
- package/expo-plugin/build/esm/utils/fileDiscovery.js.map +1 -1
- package/expo-plugin/build/esm/validation.js +2 -1
- package/expo-plugin/build/esm/validation.js.map +1 -1
- package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/index.d.ts +1 -0
- package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts +3 -1
- package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/localization.d.ts +46 -0
- package/expo-plugin/build/types/ios-widget/files/localization.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts.map +1 -1
- package/expo-plugin/build/types/types.d.ts +13 -4
- package/expo-plugin/build/types/types.d.ts.map +1 -1
- package/expo-plugin/build/types/utils/fileDiscovery.d.ts +2 -0
- package/expo-plugin/build/types/utils/fileDiscovery.d.ts.map +1 -1
- package/expo-plugin/build/types/validation.d.ts.map +1 -1
- package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +4 -0
- package/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts +9 -1
- package/expo-plugin/src/ios-widget/files/index.ts +14 -1
- package/expo-plugin/src/ios-widget/files/infoPlist.ts +23 -4
- package/expo-plugin/src/ios-widget/files/localization.ts +176 -0
- package/expo-plugin/src/ios-widget/files/swift.ts +88 -10
- package/expo-plugin/src/ios-widget/xcode/groups.ts +5 -2
- package/expo-plugin/src/types.ts +13 -3
- package/expo-plugin/src/utils/fileDiscovery.ts +7 -3
- package/expo-plugin/src/validation.ts +2 -0
- package/ios/Package.swift +8 -1
- package/ios/VoltraWidget.podspec +1 -1
- package/ios/app/NativeVoltra.mm +15 -2
- package/ios/app/VoltraModule.swift +11 -0
- package/ios/app/VoltraModuleImpl.swift +14 -0
- package/ios/app/VoltraViewComponentView.mm +2 -2
- package/ios/app/VoltraViewRoot.swift +6 -12
- package/ios/shared/ShortNames.swift +1 -0
- package/ios/shared/VoltraConstants.swift +4 -0
- package/ios/shared/VoltraElement.swift +43 -2
- package/ios/shared/VoltraLocaleEnvironment.swift +185 -0
- package/ios/shared/VoltraModifierDescriptor.swift +33 -0
- package/ios/shared/VoltraWidgetDefaults.swift +24 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +50 -28
- package/ios/target/VoltraClientWidgetRuntime.swift +12 -1
- package/ios/target/VoltraHomeWidget.swift +10 -15
- package/ios/target/VoltraWidget.swift +26 -18
- package/ios/ui/Extensions/VoltraNode+View.swift +7 -0
- package/ios/ui/Helpers/VoltraDeepLinkResolver+URL.swift +37 -0
- package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +1 -32
- package/ios/ui/Modifiers/BuiltInModifiers.swift +613 -0
- package/ios/ui/Modifiers/VoltraElement+nativeModifiers.swift +53 -0
- package/ios/ui/Modifiers/VoltraModifierRegistry.swift +181 -0
- package/ios/ui/Modifiers/VoltraRootDefaults.swift +100 -0
- package/ios/ui/Style/TextStyle.swift +4 -2
- package/ios/ui/Style/View+applyStyle.swift +1 -1
- package/ios/ui/Views/VoltraText.swift +4 -8
- package/package.json +4 -4
- package/src/dynamic-widget/api.ts +28 -0
- package/src/index.ts +6 -2
- package/src/native/NativeVoltra.ts +2 -0
- package/src/utils/useUpdateOnHMR.ts +1 -16
- package/build/commonjs/utils/helpers.js +0 -25
- package/build/commonjs/utils/helpers.js.map +0 -1
- package/build/module/utils/helpers.js +0 -4
- package/build/module/utils/helpers.js.map +0 -1
- package/build/typescript/commonjs/utils/helpers.d.ts +0 -2
- package/build/typescript/commonjs/utils/helpers.d.ts.map +0 -1
- package/build/typescript/module/utils/helpers.d.ts +0 -2
- package/build/typescript/module/utils/helpers.d.ts.map +0 -1
- 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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
)
|
package/ios/VoltraWidget.podspec
CHANGED
package/ios/app/NativeVoltra.mm
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#import "NativeVoltra.h"
|
|
2
2
|
#import <UIKit/UIKit.h>
|
|
3
3
|
|
|
4
|
-
#if __has_include("
|
|
5
|
-
#import "
|
|
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("
|
|
4
|
-
#import "
|
|
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<
|
|
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
|
|
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?.
|
|
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
|
}
|
|
@@ -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
|
|
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.
|