From 5347ca881ff982d8b1355cfe1a1a07c883c73240 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 14 Apr 2026 10:15:00 +0200 Subject: [PATCH 1/8] feat: add resolvable dynamic values for widgets across JS core and iOS Introduce the resolvable expression API, payload normalization and serialization in @use-voltra/core, wire it through the renderer and stylesheet registry, and implement evaluation on iOS (parser, evaluator, payload migration). Add Swift and Node tests, an example iOS resolvable playground widget, and use a realm-safe plain-object check so Expo prerender works with VM-evaluated styles. Made-with: Cursor --- example/app.json | 7 + example/app/_layout.tsx | 18 ++ ...ndroidOngoingNotificationTestingScreen.tsx | 2 +- .../ios/IosResolvablePlaygroundWidget.tsx | 207 ++++++++++++++++++ .../ios/ios-resolvable-playground-initial.tsx | 3 + packages/core/src/index.ts | 1 + packages/core/src/renderer/renderer.ts | 37 +--- .../core/src/renderer/stylesheet-registry.ts | 34 +-- packages/core/src/resolvable/constants.ts | 21 ++ packages/core/src/resolvable/index.ts | 10 + packages/core/src/resolvable/internal.ts | 46 ++++ packages/core/src/resolvable/normalize.ts | 117 ++++++++++ packages/core/src/resolvable/public.ts | 121 ++++++++++ packages/core/src/resolvable/serialize.ts | 153 +++++++++++++ packages/core/src/types.ts | 27 ++- packages/ios/src/index.ts | 5 + packages/ios/src/styles/types.ts | 118 +++++----- packages/ios/src/types.ts | 11 +- packages/voltra/ios/Package.swift | 13 +- .../ResolvableValueTests.swift | 147 +++++++++++++ .../Tests/VoltraSharedTests/SmokeTests.swift | 4 +- .../VoltraPayloadMigratorTests.swift | 64 ++++++ .../Resolvable/ResolvableConstants.swift | 26 +++ .../Resolvable/ResolvableEnvironment.swift | 22 ++ .../shared/Resolvable/ResolvableValue.swift | 52 +++++ .../Resolvable/ResolvableValueEvaluator.swift | 92 ++++++++ .../Resolvable/ResolvableValueParser.swift | 117 ++++++++++ .../voltra/ios/shared/VoltraElement.swift | 75 ++++++- packages/voltra/ios/shared/VoltraNode.swift | 52 +++-- .../ios/shared/VoltraPayloadMigrator.swift | 22 +- .../voltra/ios/target/VoltraHomeWidget.swift | 53 ++++- packages/voltra/ios/target/VoltraWidget.swift | 28 ++- packages/voltra/ios/ui/Voltra.swift | 30 ++- .../__tests__/resolvable-helpers.node.test.ts | 27 +++ packages/voltra/src/index.ts | 10 +- .../__tests__/resolvable.node.test.ts | 68 ++++++ packages/voltra/src/types.ts | 11 +- 37 files changed, 1669 insertions(+), 182 deletions(-) create mode 100644 example/widgets/ios/IosResolvablePlaygroundWidget.tsx create mode 100644 example/widgets/ios/ios-resolvable-playground-initial.tsx create mode 100644 packages/core/src/resolvable/constants.ts create mode 100644 packages/core/src/resolvable/index.ts create mode 100644 packages/core/src/resolvable/internal.ts create mode 100644 packages/core/src/resolvable/normalize.ts create mode 100644 packages/core/src/resolvable/public.ts create mode 100644 packages/core/src/resolvable/serialize.ts create mode 100644 packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift create mode 100644 packages/voltra/ios/Tests/VoltraSharedTests/VoltraPayloadMigratorTests.swift create mode 100644 packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift create mode 100644 packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift create mode 100644 packages/voltra/ios/shared/Resolvable/ResolvableValue.swift create mode 100644 packages/voltra/ios/shared/Resolvable/ResolvableValueEvaluator.swift create mode 100644 packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift create mode 100644 packages/voltra/src/__tests__/resolvable-helpers.node.test.ts create mode 100644 packages/voltra/src/renderer/__tests__/resolvable.node.test.ts diff --git a/example/app.json b/example/app.json index a082b0ae..81bdb64e 100644 --- a/example/app.json +++ b/example/app.json @@ -53,6 +53,13 @@ "intervalMinutes": 15, "refresh": true } + }, + { + "id": "resolvable_playground", + "displayName": "Resolvable Values", + "description": "Minimal widget playground for runtime env resolution", + "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], + "initialStatePath": "./widgets/ios/ios-resolvable-playground-initial.tsx" } ], "android": { diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index e9aa16de..efb7c638 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,9 +1,13 @@ import { Stack } from 'expo-router' +import { useEffect } from 'react' +import { Platform } from 'react-native' import { SafeAreaProvider } from 'react-native-safe-area-context' +import { reloadWidgets, updateWidget } from 'voltra/client' import { BackgroundWrapper } from '~/components/BackgroundWrapper' import { useVoltraEvents } from '~/hooks/useVoltraEvents' import { updateAndroidVoltraWidget } from '~/widgets/android/updateAndroidVoltraWidget' +import { resolvablePlaygroundVariants } from '~/widgets/ios/IosResolvablePlaygroundWidget' updateAndroidVoltraWidget({ width: 300, height: 200 }) @@ -19,6 +23,20 @@ export const unstable_settings = { export default function Layout() { useVoltraEvents() + useEffect(() => { + if (Platform.OS !== 'ios') { + return + } + void (async () => { + try { + await updateWidget('resolvable_playground', resolvablePlaygroundVariants) + await reloadWidgets(['resolvable_playground']) + } catch { + // Widget host may be unavailable (e.g. simulator without extension). + } + })() + }, []) + return ( { try { - const payloadString = renderedPayload || renderAndroidOngoingNotificationPayload(content) + const payloadString = renderAndroidOngoingNotificationPayload(content) const payload = JSON.parse(payloadString) as AndroidOngoingNotificationPayload const result = await upsertAndroidOngoingNotification(payload, getOngoingNotificationOptions()) syncActiveState(result.notificationId) diff --git a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx new file mode 100644 index 00000000..28b1fff6 --- /dev/null +++ b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx @@ -0,0 +1,207 @@ +import React from 'react' +import { and, env, eq, when, Voltra, type WidgetVariants } from '@use-voltra/ios' + +type WidgetSize = 'small' | 'medium' + +/** Per-mode label text color (avoids nesting `match` inside `when` for style types). */ +const labelByMode = when( + eq(env.renderingMode, 'accented'), + '#CBD5E1', + when(eq(env.renderingMode, 'fullColor'), '#475569', '#FBCFE8') +) + +/** Per-mode primary text / active border color. */ +const valueByMode = when( + eq(env.renderingMode, 'accented'), + '#F9FAFB', + when(eq(env.renderingMode, 'fullColor'), '#0F172A', '#FDF2F8') +) + +const Row = ({ label, children }: { label: string; children: React.ReactNode }) => { + return ( + + + {label} + + + + {children} + + + ) +} + +const IosResolvablePlaygroundBody = ({ size }: { size: WidgetSize }) => { + const compact = size === 'small' + const box = compact ? 26 : 28 + + return ( + + + + Resolvable Values + + + + + A + + + F + + + V + + + + + + Y + + + N + + + + + ) +} + +export const IosResolvablePlaygroundWidget = ({ size = 'medium' }: { size?: WidgetSize }) => { + return +} + +export const resolvablePlaygroundVariants: WidgetVariants = { + systemSmall: , + systemMedium: , + systemLarge: , +} diff --git a/example/widgets/ios/ios-resolvable-playground-initial.tsx b/example/widgets/ios/ios-resolvable-playground-initial.tsx new file mode 100644 index 00000000..cfdf0044 --- /dev/null +++ b/example/widgets/ios/ios-resolvable-playground-initial.tsx @@ -0,0 +1,3 @@ +import { resolvablePlaygroundVariants } from './IosResolvablePlaygroundWidget' + +export default resolvablePlaygroundVariants diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf73c506..2cd4bba6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from './jsx/createVoltraComponent.js' export * from './payload.js' export * from './payload/short-names.js' +export * from './resolvable/index.js' export * from './renderer/index.js' export * from './types.js' diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index e42a3f15..29c06675 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -25,11 +25,11 @@ import { import { isVoltraComponent } from '../jsx/createVoltraComponent.js' import { shorten } from '../payload/short-names.js' +import { serializeResolvablePropValue, serializeStyleObject } from '../resolvable/serialize.js' import { VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '../types.js' import { ContextRegistry, getContextRegistry } from './context-registry.js' import { getHooksDispatcher, getReactCurrentDispatcher } from './dispatcher.js' import { createElementRegistry, type ElementRegistry, preScanForDuplicates } from './element-registry.js' -import { flattenStyle } from './flatten-styles.js' import { getRenderCache, type RenderCache } from './render-cache.js' import { createStylesheetRegistry, type StylesheetRegistry } from './stylesheet-registry.js' @@ -306,35 +306,6 @@ export const renderVariantToJson = (element: ReactNode, componentRegistry: Compo return renderNode(element, context) } -function compressStyleObject(style: any): any { - if (style === null || style === undefined) { - return style - } - - const flattened = flattenStyle(style) - const compressed: Record = {} - - for (const [key, value] of Object.entries(flattened)) { - const shortKey = shorten(key) - - if (value === null || value === undefined) { - continue - } - - if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) { - const compressedNested: Record = {} - for (const [nestedKey, nestedValue] of Object.entries(value)) { - compressedNested[nestedKey] = nestedValue - } - compressed[shortKey] = compressedNested - } else { - compressed[shortKey] = value - } - } - - return compressed -} - function isReactNode(value: unknown): value is ReactNode { if (value === null || value === undefined || value === false || value === true) { return false @@ -364,7 +335,7 @@ export function transformProps( const index = context.stylesheetRegistry.registerStyle(value as object) transformed[shortKey] = index } else { - transformed[shortKey] = compressStyleObject(value) + transformed[shortKey] = serializeStyleObject(value) as VoltraPropValue } } else if (isReactNode(value)) { const serializedComponent = renderNode(value, { @@ -379,14 +350,14 @@ export function transformProps( transformed[shortKey] = serializedComponent } else { const shortKey = shorten(key) - transformed[shortKey] = value as VoltraPropValue + transformed[shortKey] = serializeResolvablePropValue(value) } } return transformed } -export const VOLTRA_PAYLOAD_VERSION = 1 +export const VOLTRA_PAYLOAD_VERSION = 2 export const createVoltraRenderer = (componentRegistry: ComponentRegistry) => { const rootNodes: { name: string; node: ReactNode }[] = [] diff --git a/packages/core/src/renderer/stylesheet-registry.ts b/packages/core/src/renderer/stylesheet-registry.ts index 53d79ca1..14b54d6e 100644 --- a/packages/core/src/renderer/stylesheet-registry.ts +++ b/packages/core/src/renderer/stylesheet-registry.ts @@ -1,34 +1,4 @@ -import { shorten } from '../payload/short-names.js' -import { flattenStyle } from './flatten-styles.js' - -function compressStyleObject(style: any): any { - if (style === null || style === undefined) { - return style - } - - const flattened = flattenStyle(style) - const compressed: Record = {} - - for (const [key, value] of Object.entries(flattened)) { - const shortKey = shorten(key) - - if (value === null || value === undefined) { - continue - } - - if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) { - const compressedNested: Record = {} - for (const [nestedKey, nestedValue] of Object.entries(value)) { - compressedNested[nestedKey] = nestedValue - } - compressed[shortKey] = compressedNested - } else { - compressed[shortKey] = value - } - } - - return compressed -} +import { serializeStyleObject } from '../resolvable/serialize.js' export type StylesheetRegistry = { registerStyle: (styleObject: object) => number @@ -46,7 +16,7 @@ export const createStylesheetRegistry = (): StylesheetRegistry => { const index = styles.length styleToIndex.set(styleObject, index) - styles.push(compressStyleObject(styleObject)) + styles.push(serializeStyleObject(styleObject) as Record) return index }, getStyles: () => styles, diff --git a/packages/core/src/resolvable/constants.ts b/packages/core/src/resolvable/constants.ts new file mode 100644 index 00000000..95f8835d --- /dev/null +++ b/packages/core/src/resolvable/constants.ts @@ -0,0 +1,21 @@ +export const RESOLVABLE_SENTINEL_KEY = '$rv' + +export const RESOLVABLE_ENV_IDS = { + renderingMode: 0, + showsWidgetContainerBackground: 1, +} as const + +export const RESOLVABLE_VALUE_OPCODES = { + env: 0, + when: 1, + match: 2, +} as const + +export const RESOLVABLE_CONDITION_OPCODES = { + eq: 0, + ne: 1, + and: 2, + or: 3, + not: 4, + inList: 5, +} as const diff --git a/packages/core/src/resolvable/index.ts b/packages/core/src/resolvable/index.ts new file mode 100644 index 00000000..6acc88e1 --- /dev/null +++ b/packages/core/src/resolvable/index.ts @@ -0,0 +1,10 @@ +export { and, env, eq, inList, isResolvableExpression, match, ne, not, or, when } from './public.js' + +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, + ResolvableExpression, + ResolvableValue, + ResolvableWidgetRenderingMode, +} from './public.js' diff --git a/packages/core/src/resolvable/internal.ts b/packages/core/src/resolvable/internal.ts new file mode 100644 index 00000000..10279349 --- /dev/null +++ b/packages/core/src/resolvable/internal.ts @@ -0,0 +1,46 @@ +import type { ResolvableEnvironmentKey } from './public.js' + +export type NormalizedResolvablePrimitive = string | number | boolean | null + +export type NormalizedResolvableJsonValue = + | NormalizedResolvablePrimitive + | NormalizedResolvableJsonValue[] + | { [key: string]: NormalizedResolvableJsonValue } + | NormalizedResolvableValue + +export type NormalizedResolvableCondition = + | { + type: 'eq' | 'ne' + left: NormalizedResolvableJsonValue + right: NormalizedResolvableJsonValue + } + | { + type: 'and' | 'or' + conditions: NormalizedResolvableCondition[] + } + | { + type: 'not' + condition: NormalizedResolvableCondition + } + | { + type: 'inList' + value: NormalizedResolvableJsonValue + values: NormalizedResolvableJsonValue[] + } + +export type NormalizedResolvableValue = + | { + type: 'env' + key: ResolvableEnvironmentKey + } + | { + type: 'when' + condition: NormalizedResolvableCondition + thenValue: NormalizedResolvableJsonValue + elseValue: NormalizedResolvableJsonValue + } + | { + type: 'match' + value: NormalizedResolvableJsonValue + cases: Record + } diff --git a/packages/core/src/resolvable/normalize.ts b/packages/core/src/resolvable/normalize.ts new file mode 100644 index 00000000..9f769e03 --- /dev/null +++ b/packages/core/src/resolvable/normalize.ts @@ -0,0 +1,117 @@ +import type { + NormalizedResolvableCondition, + NormalizedResolvableJsonValue, + NormalizedResolvableValue, +} from './internal.js' +import { isResolvableExpression } from './public.js' +import type { ResolvableCondition, ResolvableExpression } from './public.js' + +import { RESOLVABLE_SENTINEL_KEY } from './constants.js' + +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') return false + if (Array.isArray(value)) return false + return Object.prototype.toString.call(value) === '[object Object]' +} + +const isResolvableCondition = (value: unknown): value is ResolvableCondition => { + return ( + isResolvableExpression(value) && + (value.kind === 'eq' || + value.kind === 'ne' || + value.kind === 'and' || + value.kind === 'or' || + value.kind === 'not' || + value.kind === 'inList') + ) +} + +const normalizeJsonLike = (value: unknown): NormalizedResolvableJsonValue => { + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (isResolvableExpression(value)) { + if (isResolvableCondition(value)) { + throw new Error('[Voltra] Resolvable conditions can only be used inside when() or other condition builders.') + } + + return normalizeResolvableValue(value) + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeJsonLike(item)) + } + + if (isPlainObject(value)) { + if (RESOLVABLE_SENTINEL_KEY in value) { + throw new Error(`[Voltra] Object key "${RESOLVABLE_SENTINEL_KEY}" is reserved for serialized resolvable values.`) + } + + const normalized: Record = {} + for (const [key, nestedValue] of Object.entries(value)) { + if (nestedValue !== undefined) { + normalized[key] = normalizeJsonLike(nestedValue) + } + } + return normalized + } + + throw new Error(`[Voltra] Unsupported resolvable payload value of type "${typeof value}".`) +} + +const normalizeCondition = (condition: ResolvableCondition): NormalizedResolvableCondition => { + switch (condition.kind) { + case 'eq': + case 'ne': + return { + type: condition.kind, + left: normalizeJsonLike(condition.left), + right: normalizeJsonLike(condition.right), + } + case 'and': + case 'or': + return { + type: condition.kind, + conditions: condition.conditions.map((entry) => normalizeCondition(entry)), + } + case 'not': + return { + type: 'not', + condition: normalizeCondition(condition.condition), + } + case 'inList': + return { + type: 'inList', + value: normalizeJsonLike(condition.value), + values: condition.values.map((entry) => normalizeJsonLike(entry)), + } + } +} + +export const normalizeResolvableValue = (value: ResolvableExpression): NormalizedResolvableValue => { + switch (value.kind) { + case 'env': + return { + type: 'env', + key: value.key, + } + case 'when': + return { + type: 'when', + condition: normalizeCondition(value.condition), + thenValue: normalizeJsonLike(value.thenValue), + elseValue: normalizeJsonLike(value.elseValue), + } + case 'match': + return { + type: 'match', + value: normalizeJsonLike(value.value), + cases: Object.fromEntries( + Object.entries(value.cases).map(([key, caseValue]) => [key, normalizeJsonLike(caseValue)]) + ), + } + } +} + +export const normalizeResolvableJsonValue = (value: unknown): NormalizedResolvableJsonValue => normalizeJsonLike(value) diff --git a/packages/core/src/resolvable/public.ts b/packages/core/src/resolvable/public.ts new file mode 100644 index 00000000..38b7bd5a --- /dev/null +++ b/packages/core/src/resolvable/public.ts @@ -0,0 +1,121 @@ +import { RESOLVABLE_ENV_IDS } from './constants.js' + +const RESOLVABLE_BRAND = Symbol.for('VOLTRA_RESOLVABLE_BRAND') + +type ResolvablePrimitive = string | number | boolean | null + +export type ResolvableEnvironmentKey = keyof typeof RESOLVABLE_ENV_IDS +export type ResolvableWidgetRenderingMode = 'accented' | 'fullColor' | 'vibrant' + +export type ResolvableEnvironmentValueMap = { + renderingMode: ResolvableWidgetRenderingMode + showsWidgetContainerBackground: boolean +} + +type ResolvableBrand = { + readonly [RESOLVABLE_BRAND]: true +} + +export type ResolvableCondition = + | (ResolvableBrand & { + readonly kind: 'eq' | 'ne' + readonly left: ResolvableValue + readonly right: ResolvableValue + }) + | (ResolvableBrand & { + readonly kind: 'and' | 'or' + readonly conditions: readonly ResolvableCondition[] + }) + | (ResolvableBrand & { + readonly kind: 'not' + readonly condition: ResolvableCondition + }) + | (ResolvableBrand & { + readonly kind: 'inList' + readonly value: ResolvableValue + readonly values: readonly ResolvableValue[] + }) + +export type ResolvableExpression = + | (ResolvableBrand & { + readonly kind: 'env' + readonly key: ResolvableEnvironmentKey + }) + | (ResolvableBrand & { + readonly kind: 'when' + readonly condition: ResolvableCondition + readonly thenValue: ResolvableValue + readonly elseValue: ResolvableValue + }) + | (ResolvableBrand & { + readonly kind: 'match' + readonly value: ResolvableValue + readonly cases: Record> & { default: ResolvableValue } + }) + +export type ResolvableValue = [T] extends [ResolvablePrimitive] + ? T | ResolvableExpression + : [T] extends [readonly unknown[]] + ? { [K in keyof T]: ResolvableValue } | ResolvableExpression + : [T] extends [object] + ? { [K in keyof T]: ResolvableValue } | ResolvableExpression + : T | ResolvableExpression + +const createResolvable = ( + kind: TKind, + value: TValue +): TValue & + ResolvableBrand & { + readonly kind: TKind + } => { + return Object.freeze({ + ...value, + kind, + [RESOLVABLE_BRAND]: true, + }) as TValue & ResolvableBrand & { readonly kind: TKind } +} + +export const isResolvableExpression = ( + value: unknown +): value is ResolvableExpression | ResolvableCondition => { + return typeof value === 'object' && value !== null && RESOLVABLE_BRAND in value +} + +export const env: { [K in ResolvableEnvironmentKey]: ResolvableExpression } = { + renderingMode: createResolvable('env', { key: 'renderingMode' }), + showsWidgetContainerBackground: createResolvable('env', { key: 'showsWidgetContainerBackground' }), +} + +export const when = ( + condition: ResolvableCondition, + thenValue: ResolvableValue, + elseValue: ResolvableValue +): ResolvableExpression => createResolvable('when', { condition, thenValue, elseValue }) + +export const match = ( + value: ResolvableValue, + cases: Record> & { default: ResolvableValue } +): ResolvableExpression => createResolvable('match', { value, cases }) + +export const eq = ( + left: ResolvableValue, + right: ResolvableValue +): ResolvableCondition => createResolvable('eq', { left, right }) + +export const ne = ( + left: ResolvableValue, + right: ResolvableValue +): ResolvableCondition => createResolvable('ne', { left, right }) + +export const and = (...conditions: readonly ResolvableCondition[]): ResolvableCondition => + createResolvable('and', { conditions }) + +export const or = (...conditions: readonly ResolvableCondition[]): ResolvableCondition => + createResolvable('or', { conditions }) + +export const not = (condition: ResolvableCondition): ResolvableCondition => createResolvable('not', { condition }) + +export const inList = ( + value: ResolvableValue, + values: readonly ResolvableValue[] +): ResolvableCondition => createResolvable('inList', { value, values }) diff --git a/packages/core/src/resolvable/serialize.ts b/packages/core/src/resolvable/serialize.ts new file mode 100644 index 00000000..5368ae99 --- /dev/null +++ b/packages/core/src/resolvable/serialize.ts @@ -0,0 +1,153 @@ +import { shorten } from '../payload/short-names.js' +import type { + VoltraPropValue, + VoltraResolvableConditionTuple, + VoltraResolvableValueTuple, + VoltraSerializableValue, + VoltraWrappedResolvableValue, +} from '../types.js' +import { flattenStyle } from '../renderer/flatten-styles.js' +import { + RESOLVABLE_CONDITION_OPCODES, + RESOLVABLE_ENV_IDS, + RESOLVABLE_SENTINEL_KEY, + RESOLVABLE_VALUE_OPCODES, +} from './constants.js' +import type { + NormalizedResolvableCondition, + NormalizedResolvableJsonValue, + NormalizedResolvableValue, +} from './internal.js' +import { isResolvableExpression } from './public.js' +import type { ResolvableExpression } from './public.js' +import { normalizeResolvableJsonValue, normalizeResolvableValue } from './normalize.js' + +const serializeConditionTuple = (condition: NormalizedResolvableCondition): VoltraResolvableConditionTuple => { + switch (condition.type) { + case 'eq': + return [ + RESOLVABLE_CONDITION_OPCODES.eq, + serializeNormalizedJsonValue(condition.left), + serializeNormalizedJsonValue(condition.right), + ] + case 'ne': + return [ + RESOLVABLE_CONDITION_OPCODES.ne, + serializeNormalizedJsonValue(condition.left), + serializeNormalizedJsonValue(condition.right), + ] + case 'and': + return [RESOLVABLE_CONDITION_OPCODES.and, condition.conditions.map((entry) => serializeConditionTuple(entry))] + case 'or': + return [RESOLVABLE_CONDITION_OPCODES.or, condition.conditions.map((entry) => serializeConditionTuple(entry))] + case 'not': + return [RESOLVABLE_CONDITION_OPCODES.not, serializeConditionTuple(condition.condition)] + case 'inList': + return [ + RESOLVABLE_CONDITION_OPCODES.inList, + serializeNormalizedJsonValue(condition.value), + condition.values.map((entry) => serializeNormalizedJsonValue(entry)), + ] + } +} + +const wrapResolvableTuple = (tuple: VoltraResolvableValueTuple): VoltraWrappedResolvableValue => ({ + [RESOLVABLE_SENTINEL_KEY]: tuple, +}) + +const isNormalizedResolvableValue = (value: NormalizedResolvableJsonValue): value is NormalizedResolvableValue => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + + return 'type' in value && (value.type === 'env' || value.type === 'when' || value.type === 'match') +} + +const isResolvableCondition = (value: unknown): boolean => { + return ( + isResolvableExpression(value) && + (value.kind === 'eq' || + value.kind === 'ne' || + value.kind === 'and' || + value.kind === 'or' || + value.kind === 'not' || + value.kind === 'inList') + ) +} + +const isResolvableValueExpression = (value: unknown): value is ResolvableExpression => { + return isResolvableExpression(value) && !isResolvableCondition(value) +} + +const serializeResolvableTuple = (value: NormalizedResolvableValue): VoltraResolvableValueTuple => { + switch (value.type) { + case 'env': + return [RESOLVABLE_VALUE_OPCODES.env, RESOLVABLE_ENV_IDS[value.key]] + case 'when': + return [ + RESOLVABLE_VALUE_OPCODES.when, + serializeConditionTuple(value.condition), + serializeNormalizedJsonValue(value.thenValue), + serializeNormalizedJsonValue(value.elseValue), + ] + case 'match': + return [ + RESOLVABLE_VALUE_OPCODES.match, + serializeNormalizedJsonValue(value.value), + Object.fromEntries( + Object.entries(value.cases).map(([key, caseValue]) => [key, serializeNormalizedJsonValue(caseValue)]) + ), + ] + } +} + +const serializeNormalizedJsonValue = (value: NormalizedResolvableJsonValue): VoltraSerializableValue => { + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (Array.isArray(value)) { + return value.map((entry) => serializeNormalizedJsonValue(entry)) + } + + if (isNormalizedResolvableValue(value)) { + return wrapResolvableTuple(serializeResolvableTuple(value)) + } + + const serialized: Record = {} + for (const [key, nestedValue] of Object.entries(value)) { + serialized[key] = serializeNormalizedJsonValue(nestedValue) + } + return serialized +} + +export const serializeResolvablePropValue = (value: unknown): VoltraPropValue => { + const normalized = isResolvableValueExpression(value) + ? normalizeResolvableValue(value) + : normalizeResolvableJsonValue(value) + + return serializeNormalizedJsonValue(normalized) as VoltraPropValue +} + +export const serializeStyleObject = (style: unknown): VoltraSerializableValue => { + if (style === null || style === undefined) { + return style as null + } + + const flattened = flattenStyle(style as never) + if (flattened === null || flattened === undefined) { + return null + } + + const serialized: Record = {} + + for (const [key, value] of Object.entries(flattened as Record)) { + if (value === null || value === undefined) { + continue + } + + serialized[shorten(key)] = serializeResolvablePropValue(value) as VoltraSerializableValue + } + + return serialized +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7f9d2bac..32ec4a54 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,29 @@ -export type VoltraPropValue = string | number | boolean | null | VoltraNodeJson +export type VoltraJsonPrimitive = string | number | boolean | null + +export type VoltraSerializableValue = + | VoltraJsonPrimitive + | VoltraSerializableValue[] + | { [key: string]: VoltraSerializableValue } + | VoltraWrappedResolvableValue + +export type VoltraResolvableConditionTuple = + | [0, VoltraSerializableValue, VoltraSerializableValue] + | [1, VoltraSerializableValue, VoltraSerializableValue] + | [2, VoltraResolvableConditionTuple[]] + | [3, VoltraResolvableConditionTuple[]] + | [4, VoltraResolvableConditionTuple] + | [5, VoltraSerializableValue, VoltraSerializableValue[]] + +export type VoltraResolvableValueTuple = + | [0, 0 | 1] + | [1, VoltraResolvableConditionTuple, VoltraSerializableValue, VoltraSerializableValue] + | [2, VoltraSerializableValue, Record] + +export type VoltraWrappedResolvableValue = { + $rv: VoltraResolvableValueTuple +} + +export type VoltraPropValue = VoltraSerializableValue | VoltraNodeJson export type VoltraElementJson = { t: number diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index e6a07b9d..69630481 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -1,4 +1,5 @@ export * as Voltra from './jsx/primitives.js' +export { and, env, eq, inList, match, ne, not, or, when } from '@use-voltra/core' export { renderLiveActivityToJson, renderLiveActivityToString } from './live-activity/renderer.js' export type { DismissalPolicy, @@ -18,6 +19,10 @@ export type { VoltraElementRef, VoltraNodeJson, VoltraPropValue, + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableValue, + ResolvableWidgetRenderingMode, WidgetServerCredentials, } from './types.js' export { renderWidgetToJson, renderWidgetToString } from './widgets/renderer.js' diff --git a/packages/ios/src/styles/types.ts b/packages/ios/src/styles/types.ts index 2e0257b6..1ad63012 100644 --- a/packages/ios/src/styles/types.ts +++ b/packages/ios/src/styles/types.ts @@ -1,3 +1,5 @@ +import type { ResolvableValue } from '../types.js' + type StyleProp = T | T[] | null | undefined | false type VoltraTransform = @@ -14,64 +16,70 @@ type VoltraTransform = | { skewY: string } export type VoltraViewStyle = { - flex?: number - flexGrow?: number - flexShrink?: number - flexBasis?: number | string - alignItems?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' - alignSelf?: 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' - justifyContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' - flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse' - gap?: number | string - minWidth?: number | string - maxWidth?: number | string - width?: number | string - minHeight?: number | string - maxHeight?: number | string - height?: number | string - padding?: number | string - paddingTop?: number | string - paddingBottom?: number | string - paddingLeft?: number | string - paddingRight?: number | string - paddingHorizontal?: number | string - paddingVertical?: number | string - margin?: number | string - marginTop?: number | string - marginBottom?: number | string - marginLeft?: number | string - marginRight?: number | string - marginHorizontal?: number | string - marginVertical?: number | string - backgroundColor?: string - opacity?: number - borderRadius?: number | string - borderWidth?: number - borderColor?: string - shadowColor?: string - shadowOffset?: { width: number; height: number } - shadowOpacity?: number - shadowRadius?: number - overflow?: 'visible' | 'hidden' | 'scroll' - aspectRatio?: number | string - left?: number | string - top?: number | string - position?: 'absolute' | 'relative' | 'static' - zIndex?: number - transform?: VoltraTransform[] - glassEffect?: 'clear' | 'identity' | 'regular' | 'none' + flex?: ResolvableValue + flexGrow?: ResolvableValue + flexShrink?: ResolvableValue + flexBasis?: ResolvableValue + alignItems?: ResolvableValue<'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'> + alignSelf?: ResolvableValue<'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'> + justifyContent?: ResolvableValue< + 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' + > + flexDirection?: ResolvableValue<'row' | 'column' | 'row-reverse' | 'column-reverse'> + gap?: ResolvableValue + minWidth?: ResolvableValue + maxWidth?: ResolvableValue + width?: ResolvableValue + minHeight?: ResolvableValue + maxHeight?: ResolvableValue + height?: ResolvableValue + padding?: ResolvableValue + paddingTop?: ResolvableValue + paddingBottom?: ResolvableValue + paddingLeft?: ResolvableValue + paddingRight?: ResolvableValue + paddingHorizontal?: ResolvableValue + paddingVertical?: ResolvableValue + margin?: ResolvableValue + marginTop?: ResolvableValue + marginBottom?: ResolvableValue + marginLeft?: ResolvableValue + marginRight?: ResolvableValue + marginHorizontal?: ResolvableValue + marginVertical?: ResolvableValue + backgroundColor?: ResolvableValue + opacity?: ResolvableValue + borderRadius?: ResolvableValue + borderWidth?: ResolvableValue + borderColor?: ResolvableValue + shadowColor?: ResolvableValue + shadowOffset?: ResolvableValue<{ width: number; height: number }> + shadowOpacity?: ResolvableValue + shadowRadius?: ResolvableValue + overflow?: ResolvableValue<'visible' | 'hidden' | 'scroll'> + aspectRatio?: ResolvableValue + left?: ResolvableValue + top?: ResolvableValue + position?: ResolvableValue<'absolute' | 'relative' | 'static'> + zIndex?: ResolvableValue + transform?: ResolvableValue + glassEffect?: ResolvableValue<'clear' | 'identity' | 'regular' | 'none'> } export type VoltraTextStyle = VoltraViewStyle & { - fontSize?: number - fontWeight?: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' - fontFamily?: string - color?: string - letterSpacing?: number - fontVariant?: ('small-caps' | 'tabular-nums' | 'oldstyle-nums' | 'lining-nums' | 'proportional-nums')[] - textDecorationLine?: 'none' | 'underline' | 'line-through' | 'underline line-through' - lineHeight?: number - textAlign?: 'auto' | 'left' | 'right' | 'center' | 'justify' + fontSize?: ResolvableValue + fontWeight?: ResolvableValue< + 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' + > + fontFamily?: ResolvableValue + color?: ResolvableValue + letterSpacing?: ResolvableValue + fontVariant?: ResolvableValue< + ('small-caps' | 'tabular-nums' | 'oldstyle-nums' | 'lining-nums' | 'proportional-nums')[] + > + textDecorationLine?: ResolvableValue<'none' | 'underline' | 'line-through' | 'underline line-through'> + lineHeight?: ResolvableValue + textAlign?: ResolvableValue<'auto' | 'left' | 'right' | 'center' | 'justify'> } export type VoltraStyleProp = StyleProp diff --git a/packages/ios/src/types.ts b/packages/ios/src/types.ts index c4d08470..95d16c4a 100644 --- a/packages/ios/src/types.ts +++ b/packages/ios/src/types.ts @@ -1,4 +1,13 @@ -export type { VoltraElementJson, VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '@use-voltra/core' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableValue, + ResolvableWidgetRenderingMode, + VoltraElementJson, + VoltraElementRef, + VoltraNodeJson, + VoltraPropValue, +} from '@use-voltra/core' export type EventSubscription = { remove: () => void diff --git a/packages/voltra/ios/Package.swift b/packages/voltra/ios/Package.swift index 4262726f..e82fa36d 100644 --- a/packages/voltra/ios/Package.swift +++ b/packages/voltra/ios/Package.swift @@ -38,10 +38,15 @@ let package = Package( "VoltraPersistentEventQueue.swift", ], sources: [ - "JSONValue.swift", - "VoltraPayloadMigrator.swift", - "VoltraRegion.swift", - "ComponentTypeID.swift", + "JSONValue.swift", + "Resolvable/ResolvableConstants.swift", + "Resolvable/ResolvableEnvironment.swift", + "Resolvable/ResolvableValue.swift", + "Resolvable/ResolvableValueEvaluator.swift", + "Resolvable/ResolvableValueParser.swift", + "VoltraPayloadMigrator.swift", + "VoltraRegion.swift", + "ComponentTypeID.swift", ] ), .testTarget( diff --git a/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift b/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift new file mode 100644 index 00000000..45acf3ea --- /dev/null +++ b/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift @@ -0,0 +1,147 @@ +@testable import VoltraSharedCore +import XCTest + +final class ResolvableValueTests: XCTestCase { + func testParserParsesWhenExpressionIntoTypedTree() throws { + let value = wrapped([ + .int(1), + .array([ + .int(0), + wrapped([ + .int(0), + .int(0), + ]), + .string("accented"), + ]), + .object([ + "color": .string("red"), + "visible": wrapped([ + .int(0), + .int(1), + ]), + ]), + .object([ + "color": .string("blue"), + ]), + ]) + + let parsed = try ResolvableValueParser.parse(value) + + XCTAssertEqual( + parsed, + .expression(.when( + condition: .eq( + .expression(.env(.renderingMode)), + .literal(.string("accented")) + ), + thenValue: .object([ + "color": .literal(.string("red")), + "visible": .expression(.env(.showsWidgetContainerBackground)), + ]), + elseValue: .object([ + "color": .literal(.string("blue")), + ]) + )) + ) + } + + func testEvaluatorResolvesNestedObjectsAndArrays() { + let value = JSONValue.object([ + "background": wrapped([ + .int(1), + .array([ + .int(0), + wrapped([ + .int(0), + .int(0), + ]), + .string("accented"), + ]), + .string("clear"), + .string("solid"), + ]), + "layers": .array([ + .string("base"), + wrapped([ + .int(1), + .array([ + .int(0), + wrapped([ + .int(0), + .int(1), + ]), + .bool(true), + ]), + .string("widget"), + .string("app"), + ]), + ]), + ]) + + let resolved = ResolvableValueEvaluator.resolve( + value, + environment: .init(renderingMode: "accented", showsWidgetContainerBackground: true) + ) + + XCTAssertEqual( + resolved, + .object([ + "background": .string("clear"), + "layers": .array([ + .string("base"), + .string("widget"), + ]), + ]) + ) + } + + func testEvaluatorUsesDefaultMatchBranchWhenEnvIsUnavailable() { + let value = wrapped([ + .int(2), + wrapped([ + .int(0), + .int(0), + ]), + .object([ + "accented": .string("accented-value"), + "default": .string("fallback-value"), + ]), + ]) + + let resolved = ResolvableValueEvaluator.resolve(value, environment: .init()) + + XCTAssertEqual(resolved, .string("fallback-value")) + } + + func testEvaluatorReturnsNullForInvalidWrappedPayload() { + let invalidWrappedValue = JSONValue.object([ + "$rv": .string("not-a-tuple"), + ]) + + let resolved = ResolvableValueEvaluator.resolve(invalidWrappedValue, environment: .init()) + + XCTAssertEqual(resolved, .null) + } + + func testParserRejectsMatchWithoutDefaultCase() { + let value = wrapped([ + .int(2), + .string("accented"), + .object([ + "accented": .string("yes"), + ]), + ]) + + XCTAssertThrowsError(try ResolvableValueParser.parse(value)) { error in + guard case ResolvableError.missingDefaultCase = error else { + return XCTFail("Expected missingDefaultCase, got \(error)") + } + } + } + + private func wrapped(_ tuple: [JSONValue]) -> JSONValue { + .object([ + "$rv": .array(tuple), + ]) + } +} diff --git a/packages/voltra/ios/Tests/VoltraSharedTests/SmokeTests.swift b/packages/voltra/ios/Tests/VoltraSharedTests/SmokeTests.swift index 70de4920..a80e5849 100644 --- a/packages/voltra/ios/Tests/VoltraSharedTests/SmokeTests.swift +++ b/packages/voltra/ios/Tests/VoltraSharedTests/SmokeTests.swift @@ -2,7 +2,7 @@ import XCTest final class SmokeTests: XCTestCase { - func testOneEqualsOne() { - XCTAssertEqual(1, 1) + func testSharedCoreCurrentPayloadVersionIsTwo() { + XCTAssertEqual(VoltraPayloadMigrator.currentVersion, 2) } } diff --git a/packages/voltra/ios/Tests/VoltraSharedTests/VoltraPayloadMigratorTests.swift b/packages/voltra/ios/Tests/VoltraSharedTests/VoltraPayloadMigratorTests.swift new file mode 100644 index 00000000..7cf46a7d --- /dev/null +++ b/packages/voltra/ios/Tests/VoltraSharedTests/VoltraPayloadMigratorTests.swift @@ -0,0 +1,64 @@ +@testable import VoltraSharedCore +import XCTest + +final class VoltraPayloadMigratorTests: XCTestCase { + func testMigratesV1PayloadToCurrentVersion() throws { + let payload = JSONValue.object([ + "v": .int(1), + "t": .int(7), + "p": .object([ + "title": .string("Hello"), + ]), + ]) + + let migrated = try VoltraPayloadMigrator.migrateToCurrentVersion(payload) + + XCTAssertEqual( + migrated, + .object([ + "v": .int(2), + "t": .int(7), + "p": .object([ + "title": .string("Hello"), + ]), + ]) + ) + } + + func testCurrentVersionPayloadPassesThroughUnchanged() throws { + let payload = JSONValue.object([ + "v": .int(VoltraPayloadMigrator.currentVersion), + "t": .int(4), + ]) + + let migrated = try VoltraPayloadMigrator.migrateToCurrentVersion(payload) + + XCTAssertEqual(migrated, payload) + } + + func testFutureVersionPayloadReturnsNil() throws { + let payload = JSONValue.object([ + "v": .int(VoltraPayloadMigrator.currentVersion + 1), + ]) + + let migrated = try VoltraPayloadMigrator.migrateToCurrentVersion(payload) + + XCTAssertNil(migrated) + } + + func testMissingVersionThrows() { + let payload = JSONValue.object([ + "t": .int(4), + ]) + + XCTAssertThrowsError(try VoltraPayloadMigrator.migrateToCurrentVersion(payload)) { error in + XCTAssertEqual(error as? VoltraPayloadError, .missingVersion) + } + } + + func testInvalidV1StructureThrows() { + XCTAssertThrowsError(try VoltraPayloadMigrator.migrateToCurrentVersion(.array([.int(1)]))) { error in + XCTAssertEqual(error as? VoltraPayloadError, .missingVersion) + } + } +} diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift b/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift new file mode 100644 index 00000000..4facb919 --- /dev/null +++ b/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift @@ -0,0 +1,26 @@ +import Foundation + +enum ResolvableWireKey { + static let sentinel = "$rv" + static let defaultCase = "default" +} + +enum ResolvableValueOpcode: Int { + case env = 0 + case when = 1 + case match = 2 +} + +enum ResolvableConditionOpcode: Int { + case eq = 0 + case ne = 1 + case and = 2 + case or = 3 + case not = 4 + case inList = 5 +} + +public enum ResolvableEnvironmentID: Int { + case renderingMode = 0 + case showsWidgetContainerBackground = 1 +} diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift b/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift new file mode 100644 index 00000000..88be10c0 --- /dev/null +++ b/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct ResolvableEnvironment: Hashable { + public let renderingMode: String? + public let showsWidgetContainerBackground: Bool? + + public init(renderingMode: String? = nil, showsWidgetContainerBackground: Bool? = nil) { + self.renderingMode = renderingMode + self.showsWidgetContainerBackground = showsWidgetContainerBackground + } + + func value(for id: ResolvableEnvironmentID) -> JSONValue? { + switch id { + case .renderingMode: + guard let renderingMode else { return nil } + return .string(renderingMode) + case .showsWidgetContainerBackground: + guard let showsWidgetContainerBackground else { return nil } + return .bool(showsWidgetContainerBackground) + } + } +} diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableValue.swift b/packages/voltra/ios/shared/Resolvable/ResolvableValue.swift new file mode 100644 index 00000000..c5be4d06 --- /dev/null +++ b/packages/voltra/ios/shared/Resolvable/ResolvableValue.swift @@ -0,0 +1,52 @@ +import Foundation + +public indirect enum ResolvableJSONValue: Hashable { + case literal(JSONValue) + case array([ResolvableJSONValue]) + case object([String: ResolvableJSONValue]) + case expression(ResolvableExpression) +} + +public indirect enum ResolvableExpression: Hashable { + case env(ResolvableEnvironmentID) + case when(condition: ResolvableCondition, thenValue: ResolvableJSONValue, elseValue: ResolvableJSONValue) + case match(value: ResolvableJSONValue, cases: [String: ResolvableJSONValue]) +} + +public indirect enum ResolvableCondition: Hashable { + case eq(ResolvableJSONValue, ResolvableJSONValue) + case ne(ResolvableJSONValue, ResolvableJSONValue) + case and([ResolvableCondition]) + case or([ResolvableCondition]) + case not(ResolvableCondition) + case inList(ResolvableJSONValue, [ResolvableJSONValue]) +} + +enum ResolvableError: Error, LocalizedError { + case invalidWrappedValue(JSONValue) + case invalidTuple(JSONValue) + case invalidObjectShape(JSONValue) + case invalidOpcode(Int) + case invalidEnvironmentID(Int) + case missingDefaultCase + case invalidConditionTuple(JSONValue) + + var errorDescription: String? { + switch self { + case let .invalidWrappedValue(value): + return "Invalid wrapped resolvable value: \(value)" + case let .invalidTuple(value): + return "Invalid resolvable tuple: \(value)" + case let .invalidObjectShape(value): + return "Invalid resolvable object shape: \(value)" + case let .invalidOpcode(opcode): + return "Unknown resolvable opcode: \(opcode)" + case let .invalidEnvironmentID(id): + return "Unknown resolvable environment id: \(id)" + case .missingDefaultCase: + return "Resolvable match expression is missing a default case" + case let .invalidConditionTuple(value): + return "Invalid resolvable condition tuple: \(value)" + } + } +} diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableValueEvaluator.swift b/packages/voltra/ios/shared/Resolvable/ResolvableValueEvaluator.swift new file mode 100644 index 00000000..dedfe938 --- /dev/null +++ b/packages/voltra/ios/shared/Resolvable/ResolvableValueEvaluator.swift @@ -0,0 +1,92 @@ +import Foundation +import os + +public enum ResolvableValueEvaluator { + public static func resolve(_ value: JSONValue, environment: ResolvableEnvironment) -> JSONValue { + do { + let parsed = try ResolvableValueParser.parse(value) + return evaluate(parsed, environment: environment) + } catch { + logWarning(error) + return .null + } + } + + public static func evaluate(_ value: ResolvableJSONValue, environment: ResolvableEnvironment) -> JSONValue { + switch value { + case let .literal(literal): + return literal + case let .array(items): + return .array(items.map { evaluate($0, environment: environment) }) + case let .object(object): + return .object(object.mapValues { evaluate($0, environment: environment) }) + case let .expression(expression): + return evaluate(expression, environment: environment) + } + } + + static func evaluate(_ expression: ResolvableExpression, environment: ResolvableEnvironment) -> JSONValue { + switch expression { + case let .env(id): + return environment.value(for: id) ?? .null + case let .when(condition, thenValue, elseValue): + return evaluate(condition, environment: environment) + ? evaluate(thenValue, environment: environment) + : evaluate(elseValue, environment: environment) + case let .match(value, cases): + let resolvedValue = evaluate(value, environment: environment) + let key = matchCaseKey(for: resolvedValue) + let branch = cases[key] ?? cases[ResolvableWireKey.defaultCase] + guard let branch else { + logWarning(ResolvableError.missingDefaultCase) + return .null + } + return evaluate(branch, environment: environment) + } + } + + static func evaluate(_ condition: ResolvableCondition, environment: ResolvableEnvironment) -> Bool { + switch condition { + case let .eq(left, right): + return evaluate(left, environment: environment) == evaluate(right, environment: environment) + case let .ne(left, right): + return evaluate(left, environment: environment) != evaluate(right, environment: environment) + case let .and(conditions): + return conditions.allSatisfy { evaluate($0, environment: environment) } + case let .or(conditions): + return conditions.contains { evaluate($0, environment: environment) } + case let .not(condition): + return !evaluate(condition, environment: environment) + case let .inList(value, values): + let resolvedValue = evaluate(value, environment: environment) + return values.contains { evaluate($0, environment: environment) == resolvedValue } + } + } + + private static func matchCaseKey(for value: JSONValue) -> String { + switch value { + case .null: + return "null" + case let .bool(boolValue): + return boolValue ? "true" : "false" + case let .int(intValue): + return String(intValue) + case let .double(doubleValue): + if doubleValue.isFinite, doubleValue.rounded(.towardZero) == doubleValue { + return String(Int(doubleValue)) + } + return String(doubleValue) + case let .string(stringValue): + return stringValue + case let .array(arrayValue): + return String(describing: arrayValue) + case let .object(objectValue): + return String(describing: objectValue) + } + } + + private static func logWarning(_ error: Error) { + Logger(subsystem: "com.voltra", category: "resolvable") + .warning("Failed to resolve value: \(error.localizedDescription, privacy: .public)") + } +} diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift b/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift new file mode 100644 index 00000000..d4ca2e2f --- /dev/null +++ b/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift @@ -0,0 +1,117 @@ +import Foundation + +public enum ResolvableValueParser { + public static func parse(_ value: JSONValue) throws -> ResolvableJSONValue { + switch value { + case .null, .bool, .int, .double, .string: + return .literal(value) + case let .array(items): + return .array(try items.map(parse)) + case let .object(object): + if object.keys.contains(ResolvableWireKey.sentinel) { + return .expression(try parseWrappedExpression(object)) + } + + var parsed: [String: ResolvableJSONValue] = [:] + for (key, nestedValue) in object { + parsed[key] = try parse(nestedValue) + } + return .object(parsed) + } + } + + private static func parseWrappedExpression(_ object: [String: JSONValue]) throws -> ResolvableExpression { + guard object.count == 1, let tupleValue = object[ResolvableWireKey.sentinel] else { + throw ResolvableError.invalidWrappedValue(.object(object)) + } + + guard case let .array(tuple) = tupleValue, let opcodeValue = tuple.first?.intValue else { + throw ResolvableError.invalidTuple(tupleValue) + } + + guard let opcode = ResolvableValueOpcode(rawValue: opcodeValue) else { + throw ResolvableError.invalidOpcode(opcodeValue) + } + + switch opcode { + case .env: + guard tuple.count == 2, let environmentIDValue = tuple[1].intValue else { + throw ResolvableError.invalidTuple(tupleValue) + } + guard let environmentID = ResolvableEnvironmentID(rawValue: environmentIDValue) else { + throw ResolvableError.invalidEnvironmentID(environmentIDValue) + } + return .env(environmentID) + case .when: + guard tuple.count == 4 else { + throw ResolvableError.invalidTuple(tupleValue) + } + return .when( + condition: try parseCondition(tuple[1]), + thenValue: try parse(tuple[2]), + elseValue: try parse(tuple[3]) + ) + case .match: + guard tuple.count == 3 else { + throw ResolvableError.invalidTuple(tupleValue) + } + guard case let .object(casesValue) = tuple[2] else { + throw ResolvableError.invalidObjectShape(tuple[2]) + } + + var parsedCases: [String: ResolvableJSONValue] = [:] + for (key, caseValue) in casesValue { + parsedCases[key] = try parse(caseValue) + } + + guard parsedCases[ResolvableWireKey.defaultCase] != nil else { + throw ResolvableError.missingDefaultCase + } + + return .match(value: try parse(tuple[1]), cases: parsedCases) + } + } + + private static func parseCondition(_ value: JSONValue) throws -> ResolvableCondition { + guard case let .array(tuple) = value, let opcodeValue = tuple.first?.intValue else { + throw ResolvableError.invalidConditionTuple(value) + } + + guard let opcode = ResolvableConditionOpcode(rawValue: opcodeValue) else { + throw ResolvableError.invalidOpcode(opcodeValue) + } + + switch opcode { + case .eq: + guard tuple.count == 3 else { + throw ResolvableError.invalidConditionTuple(value) + } + return .eq(try parse(tuple[1]), try parse(tuple[2])) + case .ne: + guard tuple.count == 3 else { + throw ResolvableError.invalidConditionTuple(value) + } + return .ne(try parse(tuple[1]), try parse(tuple[2])) + case .and: + guard tuple.count == 2, case let .array(items) = tuple[1] else { + throw ResolvableError.invalidConditionTuple(value) + } + return .and(try items.map(parseCondition)) + case .or: + guard tuple.count == 2, case let .array(items) = tuple[1] else { + throw ResolvableError.invalidConditionTuple(value) + } + return .or(try items.map(parseCondition)) + case .not: + guard tuple.count == 2 else { + throw ResolvableError.invalidConditionTuple(value) + } + return .not(try parseCondition(tuple[1])) + case .inList: + guard tuple.count == 3, case let .array(items) = tuple[2] else { + throw ResolvableError.invalidConditionTuple(value) + } + return .inList(try parse(tuple[1]), try items.map(parse)) + } + } +} diff --git a/packages/voltra/ios/shared/VoltraElement.swift b/packages/voltra/ios/shared/VoltraElement.swift index 7599ece8..23df3af2 100644 --- a/packages/voltra/ios/shared/VoltraElement.swift +++ b/packages/voltra/ios/shared/VoltraElement.swift @@ -20,6 +20,9 @@ public struct VoltraElement: Hashable { /// Optional shared elements for resolving element references public let sharedElements: [JSONValue]? + /// Runtime-only values used when resolving wrapped payload expressions. + public let resolvableEnvironment: ResolvableEnvironment + // MARK: - Hashable public func hash(into hasher: inout Hasher) { @@ -27,13 +30,35 @@ public struct VoltraElement: Hashable { hasher.combine(id) hasher.combine(children) hasher.combine(_props) + hasher.combine(resolvableEnvironment) } public static func == (lhs: VoltraElement, rhs: VoltraElement) -> Bool { lhs.type == rhs.type && lhs.id == rhs.id && lhs.children == rhs.children && - lhs._props == rhs._props + lhs._props == rhs._props && + lhs.resolvableEnvironment == rhs.resolvableEnvironment + } + + private static let emptyJSONObjectData = Data("{}".utf8) + + private init( + type: String, + id: String?, + children: VoltraNode?, + props: [String: JSONValue]?, + stylesheet: [[String: JSONValue]]?, + sharedElements: [JSONValue]?, + resolvableEnvironment: ResolvableEnvironment + ) { + self.type = type + self.id = id + self.children = children + _props = props + self.stylesheet = stylesheet + self.sharedElements = sharedElements + self.resolvableEnvironment = resolvableEnvironment } // MARK: - Computed Properties @@ -45,7 +70,7 @@ public struct VoltraElement: Hashable { for (key, value) in props { // Expand short key to full name using unified ShortNames mapping let fullKey = ShortNames.expand(key) - expanded[fullKey] = value + expanded[fullKey] = resolveValueIfNeeded(value) } return expanded.isEmpty ? nil : expanded } @@ -63,7 +88,7 @@ public struct VoltraElement: Hashable { let stylesheet = stylesheet, index >= 0, index < stylesheet.count { - styleDict = stylesheet[index] + styleDict = stylesheet[index] } // Handle inline style (object) else if let objectValue = styleValue.objectValue { @@ -76,7 +101,7 @@ public struct VoltraElement: Hashable { for (key, value) in styleDict { // Use unified ShortNames mapping for style properties let expandedKey = ShortNames.expand(key) - expanded[expandedKey] = value + expanded[expandedKey] = resolveValueIfNeeded(value) } return expanded @@ -124,6 +149,21 @@ public struct VoltraElement: Hashable { // Store shared elements reference self.sharedElements = sharedElements + + // Resolvable payloads are evaluated lazily against the runtime environment. + resolvableEnvironment = .init() + } + + public func withResolvableEnvironment(_ environment: ResolvableEnvironment) -> VoltraElement { + .init( + type: type, + id: id, + children: children, + props: _props, + stylesheet: stylesheet, + sharedElements: sharedElements, + resolvableEnvironment: environment + ) } /// Get component prop by name - handles both single component and array @@ -137,7 +177,7 @@ public struct VoltraElement: Hashable { public func parameters(_: T.Type) -> T { guard let props = props else { // Return default instance if decoding fails - return try! JSONDecoder().decode(T.self, from: "{}".data(using: .utf8)!) + return try! JSONDecoder().decode(T.self, from: Self.emptyJSONObjectData) } do { @@ -147,7 +187,30 @@ public struct VoltraElement: Hashable { return try JSONDecoder().decode(T.self, from: jsonData) } catch { // Return default instance if decoding fails - return try! JSONDecoder().decode(T.self, from: "{}".data(using: .utf8)!) + return try! JSONDecoder().decode(T.self, from: Self.emptyJSONObjectData) + } + } + + private func resolveValueIfNeeded(_ value: JSONValue) -> JSONValue { + guard containsResolvableValue(value) else { + return value + } + + return ResolvableValueEvaluator.resolve(value, environment: resolvableEnvironment) + } + + private func containsResolvableValue(_ value: JSONValue) -> Bool { + switch value { + case .null, .bool, .int, .double, .string: + return false + case let .array(items): + return items.contains(where: containsResolvableValue) + case let .object(object): + if object.keys.contains(ResolvableWireKey.sentinel) { + return true + } + + return object.values.contains(where: containsResolvableValue) } } } diff --git a/packages/voltra/ios/shared/VoltraNode.swift b/packages/voltra/ios/shared/VoltraNode.swift index 93d5fa1d..d7732d08 100644 --- a/packages/voltra/ios/shared/VoltraNode.swift +++ b/packages/voltra/ios/shared/VoltraNode.swift @@ -117,74 +117,80 @@ public indirect enum VoltraNode: Hashable, View { struct VoltraElementView: View { let element: VoltraElement + @Environment(\.voltraEnvironment) private var voltraEnvironment + + private var resolvedElement: VoltraElement { + element.withResolvableEnvironment(voltraEnvironment.resolvableEnvironment) + } + var body: some View { - switch element.type { + switch resolvedElement.type { case "Button": - VoltraButton(element) + VoltraButton(resolvedElement) case "Link": - VoltraLink(element) + VoltraLink(resolvedElement) case "VStack": - VoltraVStack(element) + VoltraVStack(resolvedElement) case "HStack": - VoltraHStack(element) + VoltraHStack(resolvedElement) case "View": - VoltraFlexView(element) + VoltraFlexView(resolvedElement) case "ZStack": - VoltraZStack(element) + VoltraZStack(resolvedElement) case "Text": - VoltraText(element) + VoltraText(resolvedElement) case "Image": - VoltraImage(element) + VoltraImage(resolvedElement) case "Symbol": - VoltraSymbol(element) + VoltraSymbol(resolvedElement) case "Divider": - VoltraDivider(element) + VoltraDivider(resolvedElement) case "Spacer": - VoltraSpacer(element) + VoltraSpacer(resolvedElement) case "Label": - VoltraLabel(element) + VoltraLabel(resolvedElement) case "Toggle": - VoltraToggle(element) + VoltraToggle(resolvedElement) case "Gauge": - VoltraGauge(element) + VoltraGauge(resolvedElement) case "LinearProgressView": - VoltraLinearProgressView(element) + VoltraLinearProgressView(resolvedElement) case "CircularProgressView": - VoltraCircularProgressView(element) + VoltraCircularProgressView(resolvedElement) case "Timer": - VoltraTimer(element) + VoltraTimer(resolvedElement) case "GroupBox": - VoltraGroupBox(element) + VoltraGroupBox(resolvedElement) case "LinearGradient": - VoltraLinearGradient(element) + VoltraLinearGradient(resolvedElement) case "GlassContainer": - VoltraGlassContainer(element) + VoltraGlassContainer(resolvedElement) case "Mask": - VoltraMask(element) + VoltraMask(resolvedElement) case "Chart": if #available(iOS 16.0, macOS 13.0, *) { - VoltraChart(element) + VoltraChart(resolvedElement) } else { EmptyView() } diff --git a/packages/voltra/ios/shared/VoltraPayloadMigrator.swift b/packages/voltra/ios/shared/VoltraPayloadMigrator.swift index 80583d51..1cada483 100644 --- a/packages/voltra/ios/shared/VoltraPayloadMigrator.swift +++ b/packages/voltra/ios/shared/VoltraPayloadMigrator.swift @@ -21,15 +21,14 @@ public protocol VoltraPayloadMigration { /// Manages payload version migrations public enum VoltraPayloadMigrator { /// Current (latest) payload version - public static let currentVersion = 1 + public static let currentVersion = 2 /// Registered migrations, keyed by source version /// When adding a new version: /// 1. Increment currentVersion /// 2. Add migration: [oldVersion: VOldToVNewMigration.self] - private static let migrations: [Int: any VoltraPayloadMigration.Type] = [: - // Empty for v1, add as needed: - // 1: V1ToV2Migration.self, + private static let migrations: [Int: any VoltraPayloadMigration.Type] = [ + 1: V1ToV2Migration.self, ] /// Migrate a payload to the current version @@ -61,3 +60,18 @@ public enum VoltraPayloadMigrator { return currentJson } } + +private enum V1ToV2Migration: VoltraPayloadMigration { + static let fromVersion = 1 + static let toVersion = 2 + + static func migrate(_ json: JSONValue) throws -> JSONValue { + guard case let .object(payload) = json else { + throw VoltraPayloadError.invalidPayloadStructure + } + + var migrated = payload + migrated["v"] = .int(toVersion) + return .object(migrated) + } +} diff --git a/packages/voltra/ios/target/VoltraHomeWidget.swift b/packages/voltra/ios/target/VoltraHomeWidget.swift index 8cb6cf78..5b503944 100644 --- a/packages/voltra/ios/target/VoltraHomeWidget.swift +++ b/packages/voltra/ios/target/VoltraHomeWidget.swift @@ -288,9 +288,7 @@ public struct VoltraHomeWidgetView: View { Group { if let root = entry.rootNode { // No parsing here - just render the pre-parsed AST - let content = Voltra(root: root, activityId: "widget") - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .widgetURL(resolveDeepLinkURL(entry)) + let content = widgetContent(root: root) if showRefreshButton { content.overlay(alignment: .topTrailing) { @@ -306,6 +304,19 @@ public struct VoltraHomeWidgetView: View { .disableWidgetMarginsIfAvailable() } + @ViewBuilder + private func widgetContent(root: VoltraNode) -> some View { + if #available(iOSApplicationExtension 17.0, *) { + VoltraHomeWidgetResolvedRoot(root: root, activityId: "widget") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(resolveDeepLinkURL(entry)) + } else { + Voltra(root: root, activityId: "widget") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(resolveDeepLinkURL(entry)) + } + } + @ViewBuilder private var refreshButton: some View { if #available(iOSApplicationExtension 17.0, *) { @@ -338,6 +349,42 @@ public struct VoltraHomeWidgetView: View { } } +@available(iOSApplicationExtension 17.0, *) +private struct VoltraHomeWidgetResolvedRoot: View { + let root: VoltraNode + let activityId: String + + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground + + var body: some View { + Voltra( + root: root, + activityId: activityId, + resolvableEnvironment: .init( + renderingMode: renderingModeName, + showsWidgetContainerBackground: showsWidgetContainerBackground + ) + ) + } + + private var renderingModeName: String { + if widgetRenderingMode == .accented { + return "accented" + } + + if widgetRenderingMode == .fullColor { + return "fullColor" + } + + if widgetRenderingMode == .vibrant { + return "vibrant" + } + + return "fullColor" + } +} + // MARK: - Family-aware content selection /// Maps WidgetFamily to the JSON key diff --git a/packages/voltra/ios/target/VoltraWidget.swift b/packages/voltra/ios/target/VoltraWidget.swift index dcec1b83..b5eb4ff0 100644 --- a/packages/voltra/ios/target/VoltraWidget.swift +++ b/packages/voltra/ios/target/VoltraWidget.swift @@ -42,7 +42,11 @@ public struct VoltraWidget: Widget { private func defaultConfig() -> some WidgetConfiguration { ActivityConfiguration(for: VoltraAttributes.self) { context in - Voltra(root: rootNode(for: .lockScreen, from: context.state), activityId: context.activityID) + Voltra( + root: rootNode(for: .lockScreen, from: context.state), + activityId: context.activityID, + resolvableEnvironment: .init() + ) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) .voltraIfLet(context.state.activityBackgroundTint) { view, tint in let color = JSColorParser.parse(tint) @@ -65,29 +69,29 @@ public struct VoltraWidget: Widget { private func dynamicIslandContent(context: ActivityViewContext) -> DynamicIsland { let dynamicIsland = DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } DynamicIslandExpandedRegion(.trailing) { - Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } DynamicIslandExpandedRegion(.center) { - Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } DynamicIslandExpandedRegion(.bottom) { - Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } } compactLeading: { - Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } compactTrailing: { - Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } minimal: { - Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID) + Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID, resolvableEnvironment: .init()) .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) } @@ -130,17 +134,17 @@ private struct VoltraAdaptiveLockScreenView: View { // 1. Try dedicated Watch region if let nodes = context.state.regions[.supplementalActivityFamiliesSmall], !nodes.isEmpty { - Voltra(root: rootNodeProvider(.supplementalActivityFamiliesSmall, context.state), activityId: context.activityID) + Voltra(root: rootNodeProvider(.supplementalActivityFamiliesSmall, context.state), activityId: context.activityID, resolvableEnvironment: .init()) } // 2. Compose from compact Dynamic Island regions else if hasCompactContent { HStack(spacing: 0) { if !leading.isEmpty { - Voltra(root: rootNodeProvider(.islandCompactLeading, context.state), activityId: context.activityID) + Voltra(root: rootNodeProvider(.islandCompactLeading, context.state), activityId: context.activityID, resolvableEnvironment: .init()) } Spacer() if !trailing.isEmpty { - Voltra(root: rootNodeProvider(.islandCompactTrailing, context.state), activityId: context.activityID) + Voltra(root: rootNodeProvider(.islandCompactTrailing, context.state), activityId: context.activityID, resolvableEnvironment: .init()) } } .frame(maxWidth: .infinity) @@ -153,6 +157,6 @@ private struct VoltraAdaptiveLockScreenView: View { private func defaultContent() -> some View { // Default content for both StandBy (.medium) and unknown activity families - Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID) + Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID, resolvableEnvironment: .init()) } } diff --git a/packages/voltra/ios/ui/Voltra.swift b/packages/voltra/ios/ui/Voltra.swift index 22672831..990ebfa7 100644 --- a/packages/voltra/ios/ui/Voltra.swift +++ b/packages/voltra/ios/ui/Voltra.swift @@ -3,6 +3,9 @@ import SwiftUI struct VoltraEnvironment { /// Activity ID for Live Activity interactions let activityId: String + + /// Snapshot of runtime-only values used by resolvable payloads. + let resolvableEnvironment: ResolvableEnvironment } public struct Voltra: View { @@ -12,28 +15,49 @@ public struct Voltra: View { /// Activity ID for Live Activity interactions public var activityId: String + /// Snapshot of runtime-only values used by resolvable payloads. + public var resolvableEnvironment: ResolvableEnvironment + /// Initialize Voltra /// /// - Parameter root: Pre-parsed root VoltraNode /// - Parameter callback: Handler for element interactions /// - Parameter activityId: Activity ID for Live Activity interactions - public init(root: VoltraNode, activityId: String) { + public init(root: VoltraNode, activityId: String, resolvableEnvironment: ResolvableEnvironment = .init()) { self.root = root self.activityId = activityId + self.resolvableEnvironment = resolvableEnvironment } /// Generated body for SwiftUI public var body: some View { root .environment(\.voltraEnvironment, VoltraEnvironment( - activityId: activityId + activityId: activityId, + resolvableEnvironment: resolvableEnvironment )) + // Identity must change when resolvable env changes. Without this (and without + // `resolvableEnvironment` in `VoltraElement.==`), SwiftUI can skip updates when + // switching back to a previously seen `WidgetRenderingMode` (e.g. accented). + .id(resolvableSubtreeIdentity) + } + + private var resolvableSubtreeIdentity: String { + let mode = resolvableEnvironment.renderingMode ?? "" + let bg: String + if let shows = resolvableEnvironment.showsWidgetContainerBackground { + bg = shows ? "1" : "0" + } else { + bg = "nil" + } + return "\(mode)|\(bg)" } } private struct VoltraEnvironmentKey: EnvironmentKey { static let defaultValue: VoltraEnvironment = .init( - activityId: "" + activityId: "", + resolvableEnvironment: .init() ) } diff --git a/packages/voltra/src/__tests__/resolvable-helpers.node.test.ts b/packages/voltra/src/__tests__/resolvable-helpers.node.test.ts new file mode 100644 index 00000000..b986550a --- /dev/null +++ b/packages/voltra/src/__tests__/resolvable-helpers.node.test.ts @@ -0,0 +1,27 @@ +import { and, env, eq, inList, match, ne, not, or, when } from '../index' + +describe('Resolvable helpers', () => { + test('builds branded expression objects through the public surface', () => { + expect(env.renderingMode).toMatchObject({ kind: 'env', key: 'renderingMode' }) + expect(env.showsWidgetContainerBackground).toMatchObject({ + kind: 'env', + key: 'showsWidgetContainerBackground', + }) + + expect(when(eq(env.renderingMode, 'accented'), 'a', 'b')).toMatchObject({ + kind: 'when', + thenValue: 'a', + elseValue: 'b', + }) + + expect(match(env.renderingMode, { accented: 'x', default: 'y' })).toMatchObject({ + kind: 'match', + cases: { accented: 'x', default: 'y' }, + }) + + expect(and(eq(env.renderingMode, 'accented'), ne(env.renderingMode, 'vibrant'))).toMatchObject({ kind: 'and' }) + expect(or(eq(env.renderingMode, 'accented'), eq(env.renderingMode, 'fullColor'))).toMatchObject({ kind: 'or' }) + expect(not(eq(env.showsWidgetContainerBackground, true))).toMatchObject({ kind: 'not' }) + expect(inList(env.renderingMode, ['accented', 'fullColor'])).toMatchObject({ kind: 'inList' }) + }) +}) diff --git a/packages/voltra/src/index.ts b/packages/voltra/src/index.ts index be2bc816..0abbede7 100644 --- a/packages/voltra/src/index.ts +++ b/packages/voltra/src/index.ts @@ -1,3 +1,11 @@ export { VoltraAndroid } from '@use-voltra/android' export { Voltra } from '@use-voltra/ios' -export type { LiveActivityVariants, WidgetVariants } from '@use-voltra/ios' +export { and, env, eq, inList, match, ne, not, or, when } from '@use-voltra/ios' +export type { + LiveActivityVariants, + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableValue, + ResolvableWidgetRenderingMode, + WidgetVariants, +} from '@use-voltra/ios' diff --git a/packages/voltra/src/renderer/__tests__/resolvable.node.test.ts b/packages/voltra/src/renderer/__tests__/resolvable.node.test.ts new file mode 100644 index 00000000..4860965e --- /dev/null +++ b/packages/voltra/src/renderer/__tests__/resolvable.node.test.ts @@ -0,0 +1,68 @@ +import { createVoltraComponent, createVoltraRenderer, env, eq, match, when } from '@use-voltra/core' + +const componentRegistry = { + getComponentId: () => 1, +} + +const View = createVoltraComponent>('View') + +describe('Resolvable payload serialization', () => { + test('serializes nested resolvable values with the $rv sentinel', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: View, + props: { + style: { + backgroundColor: when(eq(env.renderingMode, 'accented'), 'red', 'blue'), + shadowOffset: { + width: match(env.renderingMode, { + accented: 1, + fullColor: 2, + default: 0, + }), + height: 4, + }, + }, + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 1, + p: { + s: 0, + }, + }, + s: [ + { + bg: { + $rv: [1, [0, { $rv: [0, 0] }, 'accented'], 'red', 'blue'], + }, + sho: { + width: { + $rv: [2, { $rv: [0, 0] }, { accented: 1, fullColor: 2, default: 0 }], + }, + height: 4, + }, + }, + ], + }) + }) + + test('rejects plain objects that collide with the reserved sentinel key', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: View, + props: { + metadata: { + $rv: 'reserved', + }, + }, + } as never) + + expect(() => renderer.render()).toThrow('reserved for serialized resolvable values') + }) +}) diff --git a/packages/voltra/src/types.ts b/packages/voltra/src/types.ts index 3375e7fe..7a89b8e9 100644 --- a/packages/voltra/src/types.ts +++ b/packages/voltra/src/types.ts @@ -1,4 +1,13 @@ -export type { VoltraElementJson, VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '@use-voltra/core' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableValue, + ResolvableWidgetRenderingMode, + VoltraElementJson, + VoltraElementRef, + VoltraNodeJson, + VoltraPropValue, +} from '@use-voltra/core' export type { EventSubscription, From fc595b53ac7600023f145c9f66b6bbf8256ab2b8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 14 Apr 2026 12:07:07 +0200 Subject: [PATCH 2/8] feat(android): unify Material colors with resolvable env refs Replace AndroidDynamicColors (~ string literals) with the same env/when/match API as iOS. Extend RESOLVABLE_ENV_IDS with Material role keys; authors use env.primary and siblings from voltra/android. Android parses payloads through a Kotlin resolvable evaluator after decompression; Material env ids resolve to existing ~ tokens for JSColorParser and GlanceTheme-backed Dynamic colors. iOS accepts the new env ids but resolves them to null outside Android. Re-export resolvable helpers and types from @use-voltra/android and voltra; AndroidColorValue is ResolvableValue. Update examples and tests. Made-with: Cursor --- .../android/AndroidMaterialColorsWidget.tsx | 34 +- packages/android-server/src/index.ts | 9 +- packages/android/src/dynamic-colors.ts | 35 +- packages/android/src/index.ts | 11 +- packages/android/src/server.ts | 9 +- packages/core/src/resolvable/constants.ts | 27 ++ packages/core/src/resolvable/public.ts | 35 +- packages/core/src/types.ts | 6 +- packages/ios/src/index.ts | 1 + packages/ios/src/types.ts | 1 + .../voltra/parsing/VoltraPayloadParser.kt | 6 +- .../resolvable/ResolvablePayloadResolver.kt | 75 +++++ .../ResolvableRuntimeEnvironment.kt | 28 ++ .../resolvable/ResolvableValueEvaluator.kt | 314 ++++++++++++++++++ .../java/voltra/resolvable/ResolvableWire.kt | 35 ++ .../voltra/parsing/VoltraPayloadParserTest.kt | 23 ++ .../ResolvableValueTests.swift | 14 + .../Resolvable/ResolvableConstants.swift | 27 ++ .../Resolvable/ResolvableEnvironment.swift | 3 + .../android-dynamic-color.node.test.tsx | 54 +-- .../src/__tests__/widget-server.node.test.tsx | 13 +- packages/voltra/src/android/dynamic-colors.ts | 10 +- packages/voltra/src/android/index.ts | 11 +- packages/voltra/src/android/server.ts | 9 +- packages/voltra/src/index.ts | 8 +- packages/voltra/src/types.ts | 1 + 26 files changed, 680 insertions(+), 119 deletions(-) create mode 100644 packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt create mode 100644 packages/voltra/android/src/main/java/voltra/resolvable/ResolvableRuntimeEnvironment.kt create mode 100644 packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt create mode 100644 packages/voltra/android/src/main/java/voltra/resolvable/ResolvableWire.kt diff --git a/example/widgets/android/AndroidMaterialColorsWidget.tsx b/example/widgets/android/AndroidMaterialColorsWidget.tsx index 172c6e71..803751e7 100644 --- a/example/widgets/android/AndroidMaterialColorsWidget.tsx +++ b/example/widgets/android/AndroidMaterialColorsWidget.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android' +import { env, VoltraAndroid, type AndroidColorValue } from 'voltra/android' export type AndroidMaterialColorsRenderSource = 'client' | 'server' | 'initial' @@ -20,8 +20,8 @@ const Swatch = ({ textColor, }: { label: string - backgroundColor: string - textColor: string + backgroundColor: AndroidColorValue + textColor: AndroidColorValue }) => { return ( - - {backgroundColor.slice(0, 7).toUpperCase()} - + Dynamic ) } export const AndroidMaterialColorsWidget = ({ source, renderedAt }: AndroidMaterialColorsWidgetProps) => { - const colors = AndroidDynamicColors - return ( - + Material You - + Dynamic Colors - + {SOURCE_LABELS[source]} @@ -87,9 +83,9 @@ export const AndroidMaterialColorsWidget = ({ source, renderedAt }: AndroidMater - - - + + + @@ -98,17 +94,17 @@ export const AndroidMaterialColorsWidget = ({ source, renderedAt }: AndroidMater style={{ width: '100%', flex: 1, - backgroundColor: colors.surface, + backgroundColor: env.surface, borderRadius: 20, padding: 12, }} > - + The widget should match your wallpaper-driven palette. - + Updated {renderedAt} diff --git a/packages/android-server/src/index.ts b/packages/android-server/src/index.ts index b3c2381b..80b90539 100644 --- a/packages/android-server/src/index.ts +++ b/packages/android-server/src/index.ts @@ -37,7 +37,14 @@ export type { WidgetUpdateHandler, WidgetUpdateNodeHandler, } from '@use-voltra/server' -export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from '@use-voltra/android' +export type { AndroidColorValue } from '@use-voltra/android' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, + ResolvableValue, + ResolvableWidgetRenderingMode, +} from '@use-voltra/android' export type { WidgetPlatform, WidgetTheme } from '@use-voltra/server' export type AndroidWidgetSize = { diff --git a/packages/android/src/dynamic-colors.ts b/packages/android/src/dynamic-colors.ts index 2a052834..146a41b7 100644 --- a/packages/android/src/dynamic-colors.ts +++ b/packages/android/src/dynamic-colors.ts @@ -1,33 +1,4 @@ -export const AndroidDynamicColors = { - primary: '~p', - onPrimary: '~op', - primaryContainer: '~pc', - onPrimaryContainer: '~opc', - secondary: '~s', - onSecondary: '~os', - secondaryContainer: '~sc', - onSecondaryContainer: '~osc', - tertiary: '~t', - onTertiary: '~ot', - tertiaryContainer: '~tc', - onTertiaryContainer: '~otc', - error: '~e', - errorContainer: '~ec', - onError: '~oe', - onErrorContainer: '~oec', - background: '~b', - onBackground: '~ob', - surface: '~sf', - onSurface: '~osf', - surfaceVariant: '~sv', - onSurfaceVariant: '~osv', - outline: '~ol', - inverseOnSurface: '~ios', - inverseSurface: '~is', - inversePrimary: '~ip', - widgetBackground: '~wb', -} as const +import type { ResolvableValue } from '@use-voltra/core' -export type AndroidDynamicColorRole = keyof typeof AndroidDynamicColors -export type AndroidDynamicColorToken = (typeof AndroidDynamicColors)[AndroidDynamicColorRole] -export type AndroidColorValue = string | AndroidDynamicColorToken +/** Android color props accept literals or resolvable expressions such as `env.primary`. */ +export type AndroidColorValue = ResolvableValue diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts index 2ce1363c..b9b5eda7 100644 --- a/packages/android/src/index.ts +++ b/packages/android/src/index.ts @@ -1,6 +1,6 @@ // Android component namespace export * as VoltraAndroid from './jsx/primitives.js' -export { AndroidDynamicColors } from './dynamic-colors.js' +export { and, env, eq, inList, match, ne, not, or, when } from '@use-voltra/core' export { renderAndroidLiveUpdateToJson, renderAndroidLiveUpdateToString } from './live-update/renderer.js' export { AndroidOngoingNotification } from './ongoing-notification/components.js' export { renderAndroidOngoingNotificationPayload } from './ongoing-notification/renderer.js' @@ -14,7 +14,14 @@ export type { VoltraAndroidTextStyleProp, VoltraAndroidViewStyle, } from './styles/types.js' -export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js' +export type { AndroidColorValue } from './dynamic-colors.js' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, + ResolvableValue, + ResolvableWidgetRenderingMode, +} from '@use-voltra/core' export type { AndroidLiveUpdateJson, AndroidLiveUpdateVariants, diff --git a/packages/android/src/server.ts b/packages/android/src/server.ts index 30b0151f..2046bc30 100644 --- a/packages/android/src/server.ts +++ b/packages/android/src/server.ts @@ -16,4 +16,11 @@ export type { AndroidOngoingNotificationProgressSegment, } from './ongoing-notification/types.js' export { renderAndroidWidgetToString } from './widgets/renderer.js' -export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js' +export type { AndroidColorValue } from './dynamic-colors.js' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, + ResolvableValue, + ResolvableWidgetRenderingMode, +} from '@use-voltra/core' diff --git a/packages/core/src/resolvable/constants.ts b/packages/core/src/resolvable/constants.ts index 95f8835d..bc1942c7 100644 --- a/packages/core/src/resolvable/constants.ts +++ b/packages/core/src/resolvable/constants.ts @@ -3,6 +3,33 @@ export const RESOLVABLE_SENTINEL_KEY = '$rv' export const RESOLVABLE_ENV_IDS = { renderingMode: 0, showsWidgetContainerBackground: 1, + primary: 2, + onPrimary: 3, + primaryContainer: 4, + onPrimaryContainer: 5, + secondary: 6, + onSecondary: 7, + secondaryContainer: 8, + onSecondaryContainer: 9, + tertiary: 10, + onTertiary: 11, + tertiaryContainer: 12, + onTertiaryContainer: 13, + error: 14, + errorContainer: 15, + onError: 16, + onErrorContainer: 17, + background: 18, + onBackground: 19, + surface: 20, + onSurface: 21, + surfaceVariant: 22, + onSurfaceVariant: 23, + outline: 24, + inverseOnSurface: 25, + inverseSurface: 26, + inversePrimary: 27, + widgetBackground: 28, } as const export const RESOLVABLE_VALUE_OPCODES = { diff --git a/packages/core/src/resolvable/public.ts b/packages/core/src/resolvable/public.ts index 38b7bd5a..6962e54f 100644 --- a/packages/core/src/resolvable/public.ts +++ b/packages/core/src/resolvable/public.ts @@ -7,11 +7,17 @@ type ResolvablePrimitive = string | number | boolean | null export type ResolvableEnvironmentKey = keyof typeof RESOLVABLE_ENV_IDS export type ResolvableWidgetRenderingMode = 'accented' | 'fullColor' | 'vibrant' -export type ResolvableEnvironmentValueMap = { +type ResolvableIosWidgetEnvironment = { renderingMode: ResolvableWidgetRenderingMode showsWidgetContainerBackground: boolean } +type ResolvableAndroidMaterialEnvironment = { + [K in Exclude]: string +} + +export type ResolvableEnvironmentValueMap = ResolvableIosWidgetEnvironment & ResolvableAndroidMaterialEnvironment + type ResolvableBrand = { readonly [RESOLVABLE_BRAND]: true } @@ -84,6 +90,33 @@ export const isResolvableExpression = ( export const env: { [K in ResolvableEnvironmentKey]: ResolvableExpression } = { renderingMode: createResolvable('env', { key: 'renderingMode' }), showsWidgetContainerBackground: createResolvable('env', { key: 'showsWidgetContainerBackground' }), + primary: createResolvable('env', { key: 'primary' }), + onPrimary: createResolvable('env', { key: 'onPrimary' }), + primaryContainer: createResolvable('env', { key: 'primaryContainer' }), + onPrimaryContainer: createResolvable('env', { key: 'onPrimaryContainer' }), + secondary: createResolvable('env', { key: 'secondary' }), + onSecondary: createResolvable('env', { key: 'onSecondary' }), + secondaryContainer: createResolvable('env', { key: 'secondaryContainer' }), + onSecondaryContainer: createResolvable('env', { key: 'onSecondaryContainer' }), + tertiary: createResolvable('env', { key: 'tertiary' }), + onTertiary: createResolvable('env', { key: 'onTertiary' }), + tertiaryContainer: createResolvable('env', { key: 'tertiaryContainer' }), + onTertiaryContainer: createResolvable('env', { key: 'onTertiaryContainer' }), + error: createResolvable('env', { key: 'error' }), + errorContainer: createResolvable('env', { key: 'errorContainer' }), + onError: createResolvable('env', { key: 'onError' }), + onErrorContainer: createResolvable('env', { key: 'onErrorContainer' }), + background: createResolvable('env', { key: 'background' }), + onBackground: createResolvable('env', { key: 'onBackground' }), + surface: createResolvable('env', { key: 'surface' }), + onSurface: createResolvable('env', { key: 'onSurface' }), + surfaceVariant: createResolvable('env', { key: 'surfaceVariant' }), + onSurfaceVariant: createResolvable('env', { key: 'onSurfaceVariant' }), + outline: createResolvable('env', { key: 'outline' }), + inverseOnSurface: createResolvable('env', { key: 'inverseOnSurface' }), + inverseSurface: createResolvable('env', { key: 'inverseSurface' }), + inversePrimary: createResolvable('env', { key: 'inversePrimary' }), + widgetBackground: createResolvable('env', { key: 'widgetBackground' }), } export const when = ( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 32ec4a54..7860f243 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,9 @@ +import { RESOLVABLE_ENV_IDS } from './resolvable/constants.js' + export type VoltraJsonPrimitive = string | number | boolean | null +export type VoltraResolvableEnvironmentId = (typeof RESOLVABLE_ENV_IDS)[keyof typeof RESOLVABLE_ENV_IDS] + export type VoltraSerializableValue = | VoltraJsonPrimitive | VoltraSerializableValue[] @@ -15,7 +19,7 @@ export type VoltraResolvableConditionTuple = | [5, VoltraSerializableValue, VoltraSerializableValue[]] export type VoltraResolvableValueTuple = - | [0, 0 | 1] + | [0, VoltraResolvableEnvironmentId] | [1, VoltraResolvableConditionTuple, VoltraSerializableValue, VoltraSerializableValue] | [2, VoltraSerializableValue, Record] diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index 69630481..c33a2b66 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -21,6 +21,7 @@ export type { VoltraPropValue, ResolvableCondition, ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, ResolvableValue, ResolvableWidgetRenderingMode, WidgetServerCredentials, diff --git a/packages/ios/src/types.ts b/packages/ios/src/types.ts index 95d16c4a..9f0a8729 100644 --- a/packages/ios/src/types.ts +++ b/packages/ios/src/types.ts @@ -1,6 +1,7 @@ export type { ResolvableCondition, ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, ResolvableValue, ResolvableWidgetRenderingMode, VoltraElementJson, diff --git a/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt b/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt index 68db7aca..57c93c19 100644 --- a/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt +++ b/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import voltra.models.VoltraPayload +import voltra.resolvable.AndroidWidgetResolvableEnvironment +import voltra.resolvable.ResolvablePayloadResolver @OptIn(ExperimentalSerializationApi::class) object VoltraPayloadParser { @@ -24,7 +26,9 @@ object VoltraPayloadParser { val rawResult = json.decodeFromString(jsonString) Log.d(TAG, "Decompressing payload...") - val result = VoltraDecompressor.decompress(rawResult) + val decompressed = VoltraDecompressor.decompress(rawResult) + val result = + ResolvablePayloadResolver.resolve(decompressed, AndroidWidgetResolvableEnvironment) Log.d( TAG, diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt new file mode 100644 index 00000000..8d640571 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt @@ -0,0 +1,75 @@ +package voltra.resolvable + +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.models.VoltraPayload + +/** + * Walks decompressed payloads and evaluates any `$rv` expressions so native renderers see literals + * (including Material `~` color tokens for Android env color keys). + */ +object ResolvablePayloadResolver { + fun resolve( + payload: VoltraPayload, + environment: ResolvableRuntimeEnvironment, + ): VoltraPayload = + payload.copy( + collapsed = payload.collapsed?.let { resolveNode(it, environment) }, + expanded = payload.expanded?.let { resolveNode(it, environment) }, + variants = payload.variants?.mapValues { (_, node) -> resolveNode(node, environment) }, + s = payload.s?.map { resolveMap(it, environment) }, + e = payload.e?.map { resolveNode(it, environment) }, + ) + + private fun resolveNode( + node: VoltraNode, + environment: ResolvableRuntimeEnvironment, + ): VoltraNode = + when (node) { + is VoltraNode.Element -> + VoltraNode.Element( + resolveElement(node.element, environment), + ) + is VoltraNode.Array -> + VoltraNode.Array(node.elements.map { resolveNode(it, environment) }) + else -> node + } + + private fun resolveElement( + element: VoltraElement, + environment: ResolvableRuntimeEnvironment, + ): VoltraElement = + element.copy( + p = element.p?.let { resolveMap(it, environment) }, + c = element.c?.let { resolveNode(it, environment) }, + ) + + private fun resolveMap( + map: Map, + environment: ResolvableRuntimeEnvironment, + ): Map { + val result = LinkedHashMap() + for ((key, value) in map) { + result[key] = resolveValue(value, environment) + } + return result + } + + private fun resolveValue( + value: Any?, + environment: ResolvableRuntimeEnvironment, + ): Any? = + when (value) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + val m = value as Map + if (m.size == 1 && m.containsKey(ResolvableWireKey.SENTINEL)) { + ResolvableValueEvaluator.resolveRoot(value, environment) + } else { + resolveMap(m, environment) + } + } + is List<*> -> value.map { resolveValue(it, environment) } + else -> value + } +} diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableRuntimeEnvironment.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableRuntimeEnvironment.kt new file mode 100644 index 00000000..27eb6825 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableRuntimeEnvironment.kt @@ -0,0 +1,28 @@ +package voltra.resolvable + +import voltra.styling.VoltraThemeColorRole + +/** + * Runtime inputs for evaluating `$rv` payloads. Android widgets resolve Material color env IDs to + * the same `~` tokens consumed by [voltra.styling.JSColorParser]. + */ +fun interface ResolvableRuntimeEnvironment { + fun envValue(envId: Int): Any? +} + +/** Default Android widget environment: iOS-only env IDs are absent; Material roles map to `~` tokens. */ +object AndroidWidgetResolvableEnvironment : ResolvableRuntimeEnvironment { + private const val FIRST_ANDROID_MATERIAL_ENV_ID = 2 + + override fun envValue(envId: Int): Any? { + if (envId < FIRST_ANDROID_MATERIAL_ENV_ID) { + return null + } + val index = envId - FIRST_ANDROID_MATERIAL_ENV_ID + val roles = VoltraThemeColorRole.entries + if (index !in roles.indices) { + return null + } + return roles[index].token + } +} diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt new file mode 100644 index 00000000..b48dfe9f --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt @@ -0,0 +1,314 @@ +package voltra.resolvable + +import android.util.Log +import kotlin.math.floor + +private const val TAG = "ResolvableValue" + +/** + * Parses and evaluates Voltra `$rv` resolvable payloads (same wire format as iOS). + */ +internal object ResolvableValueEvaluator { + fun resolveRoot( + value: Any?, + environment: ResolvableRuntimeEnvironment, + ): Any? { + if (!containsResolvable(value)) { + return value + } + return try { + val parsed = parse(value) + evaluate(parsed, environment) + } catch (e: Exception) { + logWarning("Failed to resolve value: ${e.message}", e) + null + } + } + + private sealed class Parsed { + data class Literal( + val value: Any?, + ) : Parsed() + + data class ArrayVal( + val items: List, + ) : Parsed() + + data class Obj( + val map: Map, + ) : Parsed() + + data class Expr( + val expr: ResolvableExpr, + ) : Parsed() + } + + private sealed class ResolvableExpr { + data class Env( + val envId: Int, + ) : ResolvableExpr() + + data class When( + val condition: Condition, + val thenValue: Parsed, + val elseValue: Parsed, + ) : ResolvableExpr() + + data class Match( + val value: Parsed, + val cases: Map, + ) : ResolvableExpr() + } + + private sealed class Condition { + data class Eq( + val left: Parsed, + val right: Parsed, + ) : Condition() + + data class Ne( + val left: Parsed, + val right: Parsed, + ) : Condition() + + data class And( + val conditions: List, + ) : Condition() + + data class Or( + val conditions: List, + ) : Condition() + + data class Not( + val condition: Condition, + ) : Condition() + + data class InList( + val value: Parsed, + val values: List, + ) : Condition() + } + + private fun parse(value: Any?): Parsed = + when (value) { + null, is String, is Boolean, is Number -> Parsed.Literal(value) + is List<*> -> Parsed.ArrayVal(value.map { parse(it) }) + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + val map = value as Map + if (ResolvableWireKey.SENTINEL in map) { + Parsed.Expr(parseWrappedExpression(map)) + } else { + val parsed = + map.mapValues { (_, nested) -> + parse(nested) + } + Parsed.Obj(parsed) + } + } + else -> Parsed.Literal(value) + } + + private fun parseWrappedExpression(map: Map): ResolvableExpr { + require(map.size == 1) { "Invalid wrapped resolvable value" } + val tupleValue = map[ResolvableWireKey.SENTINEL] ?: error("Missing \$rv tuple") + require(tupleValue is List<*>) { "Invalid resolvable tuple" } + @Suppress("UNCHECKED_CAST") + val tuple = tupleValue as List + val opcodeRaw = tuple.firstOrNull().asOpcodeInt() ?: error("Invalid resolvable opcode") + val opcode = + ResolvableValueOpcode.fromRaw(opcodeRaw) ?: error("Unknown resolvable opcode: $opcodeRaw") + return when (opcode) { + ResolvableValueOpcode.ENV -> { + require(tuple.size == 2) { "Invalid env tuple" } + val envId = tuple[1].asOpcodeInt() ?: error("Invalid environment id") + ResolvableExpr.Env(envId) + } + ResolvableValueOpcode.WHEN -> { + require(tuple.size == 4) { "Invalid when tuple" } + ResolvableExpr.When( + condition = parseCondition(tuple[1]), + thenValue = parse(tuple[2]), + elseValue = parse(tuple[3]), + ) + } + ResolvableValueOpcode.MATCH -> { + require(tuple.size == 3) { "Invalid match tuple" } + val casesRaw = tuple[2] + require(casesRaw is Map<*, *>) { "Invalid match cases" } + @Suppress("UNCHECKED_CAST") + val casesMap = casesRaw as Map + val parsedCases = casesMap.mapValues { (_, v) -> parse(v) } + require(ResolvableWireKey.DEFAULT_CASE in parsedCases) { "Resolvable match expression is missing a default case" } + ResolvableExpr.Match(value = parse(tuple[1]), cases = parsedCases) + } + } + } + + private fun parseCondition(value: Any?): Condition { + require(value is List<*>) { "Invalid resolvable condition tuple" } + @Suppress("UNCHECKED_CAST") + val tuple = value as List + val opcodeRaw = tuple.firstOrNull().asOpcodeInt() ?: error("Invalid condition opcode") + val opcode = + ResolvableConditionOpcode.fromRaw(opcodeRaw) + ?: error("Unknown resolvable condition opcode: $opcodeRaw") + return when (opcode) { + ResolvableConditionOpcode.EQ -> { + require(tuple.size == 3) { "Invalid eq tuple" } + Condition.Eq(parse(tuple[1]), parse(tuple[2])) + } + ResolvableConditionOpcode.NE -> { + require(tuple.size == 3) { "Invalid ne tuple" } + Condition.Ne(parse(tuple[1]), parse(tuple[2])) + } + ResolvableConditionOpcode.AND -> { + require(tuple.size == 2 && tuple[1] is List<*>) { "Invalid and tuple" } + @Suppress("UNCHECKED_CAST") + val items = tuple[1] as List + Condition.And(items.map { parseCondition(it) }) + } + ResolvableConditionOpcode.OR -> { + require(tuple.size == 2 && tuple[1] is List<*>) { "Invalid or tuple" } + @Suppress("UNCHECKED_CAST") + val items = tuple[1] as List + Condition.Or(items.map { parseCondition(it) }) + } + ResolvableConditionOpcode.NOT -> { + require(tuple.size == 2) { "Invalid not tuple" } + Condition.Not(parseCondition(tuple[1])) + } + ResolvableConditionOpcode.IN_LIST -> { + require(tuple.size == 3 && tuple[2] is List<*>) { "Invalid inList tuple" } + @Suppress("UNCHECKED_CAST") + val items = tuple[2] as List + Condition.InList(parse(tuple[1]), items.map { parse(it) }) + } + } + } + + private fun evaluate( + parsed: Parsed, + environment: ResolvableRuntimeEnvironment, + ): Any? = + when (parsed) { + is Parsed.Literal -> parsed.value + is Parsed.ArrayVal -> parsed.items.map { evaluate(it, environment) } + is Parsed.Obj -> parsed.map.mapValues { (_, v) -> evaluate(v, environment) } + is Parsed.Expr -> evaluate(parsed.expr, environment) + } + + private fun evaluate( + expr: ResolvableExpr, + environment: ResolvableRuntimeEnvironment, + ): Any? = + when (expr) { + is ResolvableExpr.Env -> environment.envValue(expr.envId) + is ResolvableExpr.When -> + if (evaluate(expr.condition, environment)) { + evaluate(expr.thenValue, environment) + } else { + evaluate(expr.elseValue, environment) + } + is ResolvableExpr.Match -> { + val resolved = evaluate(expr.value, environment) + val key = matchCaseKey(resolved) + val branch = expr.cases[key] ?: expr.cases[ResolvableWireKey.DEFAULT_CASE] + if (branch == null) { + logWarning("Resolvable match missing default case", null) + null + } else { + evaluate(branch, environment) + } + } + } + + private fun evaluate( + condition: Condition, + environment: ResolvableRuntimeEnvironment, + ): Boolean = + when (condition) { + is Condition.Eq -> + jsonEquals( + evaluate(condition.left, environment), + evaluate(condition.right, environment), + ) + is Condition.Ne -> + !jsonEquals( + evaluate(condition.left, environment), + evaluate(condition.right, environment), + ) + is Condition.And -> condition.conditions.all { evaluate(it, environment) } + is Condition.Or -> condition.conditions.any { evaluate(it, environment) } + is Condition.Not -> !evaluate(condition.condition, environment) + is Condition.InList -> { + val resolved = evaluate(condition.value, environment) + condition.values.any { jsonEquals(evaluate(it, environment), resolved) } + } + } + + private fun matchCaseKey(value: Any?): String = + when (value) { + null -> "null" + is Boolean -> if (value) "true" else "false" + is Number -> { + val d = value.toDouble() + if (d.isFinite() && floor(d) == d && d >= Long.MIN_VALUE.toDouble() && d <= Long.MAX_VALUE.toDouble()) { + d.toLong().toString() + } else { + d.toString() + } + } + is String -> value + is List<*> -> value.toString() + is Map<*, *> -> value.toString() + else -> value.toString() + } + + private fun jsonEquals( + a: Any?, + b: Any?, + ): Boolean = + when { + a == null && b == null -> true + a == null || b == null -> false + a is Number && b is Number -> a.toDouble() == b.toDouble() + else -> a == b + } + + private fun containsResolvable(value: Any?): Boolean = + when (value) { + null, is String, is Boolean, is Number -> false + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + val m = value as Map + m.containsKey(ResolvableWireKey.SENTINEL) || m.values.any { containsResolvable(it) } + } + is List<*> -> value.any { containsResolvable(it) } + else -> false + } + + private fun Any?.asOpcodeInt(): Int? = + when (this) { + is Int -> this + is Long -> this.toInt() + is Double -> if (floor(this) == this) this.toInt() else null + is Float -> if (floor(this.toDouble()) == this.toDouble()) this.toInt() else null + else -> null + } + + private fun logWarning( + message: String, + error: Throwable?, + ) { + try { + if (error != null) { + Log.w(TAG, message, error) + } else { + Log.w(TAG, message) + } + } catch (_: RuntimeException) { + // Unit tests may not provide android.util.Log. + } + } +} diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableWire.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableWire.kt new file mode 100644 index 00000000..df8fae2a --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableWire.kt @@ -0,0 +1,35 @@ +package voltra.resolvable + +internal object ResolvableWireKey { + const val SENTINEL = "\$rv" + const val DEFAULT_CASE = "default" +} + +internal enum class ResolvableValueOpcode( + val raw: Int, +) { + ENV(0), + WHEN(1), + MATCH(2), + ; + + companion object { + fun fromRaw(raw: Int): ResolvableValueOpcode? = entries.firstOrNull { it.raw == raw } + } +} + +internal enum class ResolvableConditionOpcode( + val raw: Int, +) { + EQ(0), + NE(1), + AND(2), + OR(3), + NOT(4), + IN_LIST(5), + ; + + companion object { + fun fromRaw(raw: Int): ResolvableConditionOpcode? = entries.firstOrNull { it.raw == raw } + } +} diff --git a/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt b/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt index d7826df5..ac61290e 100644 --- a/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt +++ b/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt @@ -130,6 +130,29 @@ class VoltraPayloadParserTest { assertTrue(listChildren.elements[1] is VoltraNode.Element) } + @Test + fun resolvesAndroidMaterialColorEnvInStyleToToken() { + val payload = + VoltraPayloadParser.parse( + """ + { + "v": 2, + "collapsed": { + "t": 18, + "p": { + "txt": "Hi", + "s": { "c": { "${'$'}rv": [0, 2] } } + } + } + } + """.trimIndent(), + ) + + val collapsed = (payload.collapsed as VoltraNode.Element).element + val style = collapsed.p?.get("style") as Map<*, *> + assertEquals("~p", style["color"]) + } + @Test fun decompressesShortenedKeysInNestedPropsWithoutChangingElementShape() { val payload = diff --git a/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift b/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift index 45acf3ea..928e9cf0 100644 --- a/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift +++ b/packages/voltra/ios/Tests/VoltraSharedTests/ResolvableValueTests.swift @@ -113,6 +113,20 @@ final class ResolvableValueTests: XCTestCase { XCTAssertEqual(resolved, .string("fallback-value")) } + func testAndroidMaterialEnvResolvesToNullOnIos() { + let value = wrapped([ + .int(0), + .int(2), + ]) + + let resolved = ResolvableValueEvaluator.resolve( + value, + environment: .init(renderingMode: "accented", showsWidgetContainerBackground: true) + ) + + XCTAssertEqual(resolved, .null) + } + func testEvaluatorReturnsNullForInvalidWrappedPayload() { let invalidWrappedValue = JSONValue.object([ "$rv": .string("not-a-tuple"), diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift b/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift index 4facb919..557acf59 100644 --- a/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift +++ b/packages/voltra/ios/shared/Resolvable/ResolvableConstants.swift @@ -23,4 +23,31 @@ enum ResolvableConditionOpcode: Int { public enum ResolvableEnvironmentID: Int { case renderingMode = 0 case showsWidgetContainerBackground = 1 + case primary = 2 + case onPrimary = 3 + case primaryContainer = 4 + case onPrimaryContainer = 5 + case secondary = 6 + case onSecondary = 7 + case secondaryContainer = 8 + case onSecondaryContainer = 9 + case tertiary = 10 + case onTertiary = 11 + case tertiaryContainer = 12 + case onTertiaryContainer = 13 + case error = 14 + case errorContainer = 15 + case onError = 16 + case onErrorContainer = 17 + case background = 18 + case onBackground = 19 + case surface = 20 + case onSurface = 21 + case surfaceVariant = 22 + case onSurfaceVariant = 23 + case outline = 24 + case inverseOnSurface = 25 + case inverseSurface = 26 + case inversePrimary = 27 + case widgetBackground = 28 } diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift b/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift index 88be10c0..717db503 100644 --- a/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift +++ b/packages/voltra/ios/shared/Resolvable/ResolvableEnvironment.swift @@ -17,6 +17,9 @@ public struct ResolvableEnvironment: Hashable { case .showsWidgetContainerBackground: guard let showsWidgetContainerBackground else { return nil } return .bool(showsWidgetContainerBackground) + default: + // Android Material color env keys are inert on iOS; payloads should not rely on them here. + return nil } } } diff --git a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx index 289c1229..2a223c4a 100644 --- a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx +++ b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx @@ -1,68 +1,38 @@ import React from 'react' -import { AndroidDynamicColors, VoltraAndroid } from '../android/index.js' +import { env, VoltraAndroid } from '../android/index.js' import { renderAndroidViewToJson, renderAndroidWidgetToString } from '../android/widgets/renderer.js' -describe('Android semantic color tokens', () => { - it('exports the stable token map', () => { - expect(AndroidDynamicColors).toEqual({ - primary: '~p', - onPrimary: '~op', - primaryContainer: '~pc', - onPrimaryContainer: '~opc', - secondary: '~s', - onSecondary: '~os', - secondaryContainer: '~sc', - onSecondaryContainer: '~osc', - tertiary: '~t', - onTertiary: '~ot', - tertiaryContainer: '~tc', - onTertiaryContainer: '~otc', - error: '~e', - errorContainer: '~ec', - onError: '~oe', - onErrorContainer: '~oec', - background: '~b', - onBackground: '~ob', - surface: '~sf', - onSurface: '~osf', - surfaceVariant: '~sv', - onSurfaceVariant: '~osv', - outline: '~ol', - inverseOnSurface: '~ios', - inverseSurface: '~is', - inversePrimary: '~ip', - widgetBackground: '~wb', - }) - }) - - it('preserves semantic color tokens in rendered widget payloads', () => { +describe('Android Material colors via resolvable env', () => { + it('serializes env.primary as a wrapped resolvable payload', () => { const output = renderAndroidWidgetToString([ { size: { width: 150, height: 100 }, content: ( ), }, ]) - expect(output).toContain('"~p"') - expect(output).toContain('"~op"') + expect(output).toContain('"$rv"') + expect(output).toContain('[0,2]') + expect(output).toContain('[0,3]') }) - it('renders Android views without palette context injection', () => { + it('renders Android views with resolvable color expressions in styles', () => { const output = renderAndroidViewToJson( - Hello + Hello ) as { variants?: Record s?: Array> } expect(output.variants?.content?.c).toBe('Hello') - expect(JSON.stringify(output.s ?? [])).toContain('~osf') + expect(JSON.stringify(output.s ?? [])).toContain('$rv') + expect(JSON.stringify(output.s ?? [])).toContain('[0,21]') }) }) diff --git a/packages/voltra/src/__tests__/widget-server.node.test.tsx b/packages/voltra/src/__tests__/widget-server.node.test.tsx index cd15dc91..e9114e22 100644 --- a/packages/voltra/src/__tests__/widget-server.node.test.tsx +++ b/packages/voltra/src/__tests__/widget-server.node.test.tsx @@ -1,7 +1,7 @@ import { createAndroidWidgetUpdateHandler, renderAndroidWidgetToString } from '@use-voltra/android-server' import { createIOSWidgetUpdateHandler, renderWidgetToString, Voltra } from '@use-voltra/ios-server' import { createWidgetUpdateHandler as createSharedWidgetUpdateHandler } from '@use-voltra/server' -import { AndroidDynamicColors, VoltraAndroid } from '../android/index.js' +import { env, VoltraAndroid } from '../android/index.js' import { createWidgetUpdateHandler as createCompatibilityWidgetUpdateHandler } from '../widget-server.js' describe('server package split', () => { @@ -142,15 +142,15 @@ describe('server package split', () => { expect(await response.text()).toBe(renderAndroidWidgetToString(variants)) }) - it('preserves Android semantic color tokens through server handlers', async () => { + it('preserves Android Material env color expressions through server handlers', async () => { const variants = [ { size: { width: 150, height: 100 }, content: ( ), }, @@ -163,8 +163,9 @@ describe('server package split', () => { const payload = await response.text() expect(response.status).toBe(200) - expect(payload).toContain('"~p"') - expect(payload).toContain('"~op"') + expect(payload).toContain('"$rv"') + expect(payload).toContain('[0,2]') + expect(payload).toContain('[0,3]') }) it('keeps the root compatibility handler API unchanged', async () => { diff --git a/packages/voltra/src/android/dynamic-colors.ts b/packages/voltra/src/android/dynamic-colors.ts index ebdb2ad6..146a41b7 100644 --- a/packages/voltra/src/android/dynamic-colors.ts +++ b/packages/voltra/src/android/dynamic-colors.ts @@ -1,6 +1,4 @@ -export { - AndroidDynamicColors, - type AndroidColorValue, - type AndroidDynamicColorRole, - type AndroidDynamicColorToken, -} from '@use-voltra/android' +import type { ResolvableValue } from '@use-voltra/core' + +/** Android color props accept literals or resolvable expressions such as `env.primary`. */ +export type AndroidColorValue = ResolvableValue diff --git a/packages/voltra/src/android/index.ts b/packages/voltra/src/android/index.ts index ea109485..464289af 100644 --- a/packages/voltra/src/android/index.ts +++ b/packages/voltra/src/android/index.ts @@ -1,5 +1,5 @@ export * as VoltraAndroid from './jsx/primitives.js' -export { AndroidDynamicColors } from './dynamic-colors.js' +export { and, env, eq, inList, match, ne, not, or, when } from '@use-voltra/core' export { AndroidOngoingNotification } from '@use-voltra/android' export type { VoltraAndroidBaseProps } from './jsx/baseProps.js' @@ -9,7 +9,14 @@ export type { VoltraAndroidTextStyleProp, VoltraAndroidViewStyle, } from './styles/types.js' -export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js' +export type { AndroidColorValue } from './dynamic-colors.js' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, + ResolvableValue, + ResolvableWidgetRenderingMode, +} from '@use-voltra/core' export type { BoxProps } from './jsx/Box.js' export type { ButtonProps } from './jsx/Button.js' diff --git a/packages/voltra/src/android/server.ts b/packages/voltra/src/android/server.ts index 3eb83ced..6876a2f4 100644 --- a/packages/voltra/src/android/server.ts +++ b/packages/voltra/src/android/server.ts @@ -22,4 +22,11 @@ export { renderAndroidWidgetToString, type AndroidWidgetRenderOptions, } from './widgets/renderer.js' -export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js' +export type { AndroidColorValue } from './dynamic-colors.js' +export type { + ResolvableCondition, + ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, + ResolvableValue, + ResolvableWidgetRenderingMode, +} from '@use-voltra/core' diff --git a/packages/voltra/src/index.ts b/packages/voltra/src/index.ts index 0abbede7..9465519c 100644 --- a/packages/voltra/src/index.ts +++ b/packages/voltra/src/index.ts @@ -1,11 +1,11 @@ export { VoltraAndroid } from '@use-voltra/android' export { Voltra } from '@use-voltra/ios' -export { and, env, eq, inList, match, ne, not, or, when } from '@use-voltra/ios' +export { and, env, eq, inList, match, ne, not, or, when } from '@use-voltra/core' export type { - LiveActivityVariants, ResolvableCondition, ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, ResolvableValue, ResolvableWidgetRenderingMode, - WidgetVariants, -} from '@use-voltra/ios' +} from '@use-voltra/core' +export type { LiveActivityVariants, WidgetVariants } from '@use-voltra/ios' diff --git a/packages/voltra/src/types.ts b/packages/voltra/src/types.ts index 7a89b8e9..cecac34c 100644 --- a/packages/voltra/src/types.ts +++ b/packages/voltra/src/types.ts @@ -1,6 +1,7 @@ export type { ResolvableCondition, ResolvableEnvironmentKey, + ResolvableEnvironmentValueMap, ResolvableValue, ResolvableWidgetRenderingMode, VoltraElementJson, From c3f9dac29fc74a0212607cd792df029aae6d722a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 15 Apr 2026 07:55:24 +0200 Subject: [PATCH 3/8] refactor: deduplicate isResolvableCondition guard and remove narrating comment Export isResolvableCondition from normalize.ts and import it in serialize.ts instead of maintaining an identical (but weaker-typed) local copy. Also remove a comment in VoltraElement.swift that narrated the code rather than explaining a non-obvious constraint. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/resolvable/normalize.ts | 2 +- packages/core/src/resolvable/serialize.ts | 14 +------------- packages/voltra/ios/shared/VoltraElement.swift | 1 - 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/core/src/resolvable/normalize.ts b/packages/core/src/resolvable/normalize.ts index 9f769e03..07bb4d33 100644 --- a/packages/core/src/resolvable/normalize.ts +++ b/packages/core/src/resolvable/normalize.ts @@ -14,7 +14,7 @@ const isPlainObject = (value: unknown): value is Record => { return Object.prototype.toString.call(value) === '[object Object]' } -const isResolvableCondition = (value: unknown): value is ResolvableCondition => { +export const isResolvableCondition = (value: unknown): value is ResolvableCondition => { return ( isResolvableExpression(value) && (value.kind === 'eq' || diff --git a/packages/core/src/resolvable/serialize.ts b/packages/core/src/resolvable/serialize.ts index 5368ae99..e101bb89 100644 --- a/packages/core/src/resolvable/serialize.ts +++ b/packages/core/src/resolvable/serialize.ts @@ -20,7 +20,7 @@ import type { } from './internal.js' import { isResolvableExpression } from './public.js' import type { ResolvableExpression } from './public.js' -import { normalizeResolvableJsonValue, normalizeResolvableValue } from './normalize.js' +import { isResolvableCondition, normalizeResolvableJsonValue, normalizeResolvableValue } from './normalize.js' const serializeConditionTuple = (condition: NormalizedResolvableCondition): VoltraResolvableConditionTuple => { switch (condition.type) { @@ -63,18 +63,6 @@ const isNormalizedResolvableValue = (value: NormalizedResolvableJsonValue): valu return 'type' in value && (value.type === 'env' || value.type === 'when' || value.type === 'match') } -const isResolvableCondition = (value: unknown): boolean => { - return ( - isResolvableExpression(value) && - (value.kind === 'eq' || - value.kind === 'ne' || - value.kind === 'and' || - value.kind === 'or' || - value.kind === 'not' || - value.kind === 'inList') - ) -} - const isResolvableValueExpression = (value: unknown): value is ResolvableExpression => { return isResolvableExpression(value) && !isResolvableCondition(value) } diff --git a/packages/voltra/ios/shared/VoltraElement.swift b/packages/voltra/ios/shared/VoltraElement.swift index 23df3af2..950c3a5b 100644 --- a/packages/voltra/ios/shared/VoltraElement.swift +++ b/packages/voltra/ios/shared/VoltraElement.swift @@ -150,7 +150,6 @@ public struct VoltraElement: Hashable { // Store shared elements reference self.sharedElements = sharedElements - // Resolvable payloads are evaluated lazily against the runtime environment. resolvableEnvironment = .init() } From 2175a0c89a6a74f3f3f5eefe5be1a36491ea34ef Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 15 Apr 2026 08:52:13 +0200 Subject: [PATCH 4/8] feat: support ResolvableValues in Text --- packages/core/src/renderer/renderer.ts | 18 +++++++++++++++++- packages/core/src/resolvable/serialize.ts | 2 +- packages/ios/src/jsx/Text.tsx | 6 ++++-- .../glance/renderers/TextAndImageRenderers.kt | 2 +- packages/voltra/ios/ui/Views/VoltraText.swift | 5 +++++ packages/voltra/src/android/jsx/Text.tsx | 6 ++++-- packages/voltra/src/jsx/Text.tsx | 6 ++++-- 7 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 29c06675..5b35eaaf 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -25,7 +25,7 @@ import { import { isVoltraComponent } from '../jsx/createVoltraComponent.js' import { shorten } from '../payload/short-names.js' -import { serializeResolvablePropValue, serializeStyleObject } from '../resolvable/serialize.js' +import { isResolvableValueExpression, serializeResolvablePropValue, serializeStyleObject } from '../resolvable/serialize.js' import { VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '../types.js' import { ContextRegistry, getContextRegistry } from './context-registry.js' import { getHooksDispatcher, getReactCurrentDispatcher } from './dispatcher.js' @@ -208,6 +208,22 @@ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext) const { children, ...parameters } = child.props as { children?: ReactNode; [key: string]: unknown } const isTextComponent = child.type === 'Text' || child.type === 'AndroidText' + + // Short-circuit: ResolvableExpression as children of a Text component is serialized + // via p.txt (wire key for "text") so the native resolver handles it at render time. + if (isTextComponent && isResolvableValueExpression(children)) { + const id = typeof parameters.id === 'string' ? parameters.id : undefined + const { id: _id, ...cleanParameters } = parameters + const transformedProps = transformProps({ ...cleanParameters, text: children }, context) + const hasProps = Object.keys(transformedProps).length > 0 + return { + t: context.componentRegistry.getComponentId(child.type), + ...(id ? { i: id } : {}), + c: '', + ...(hasProps ? { p: transformedProps } : {}), + } + } + const childContext: VoltraRenderingContext = { ...context, inStringOnlyContext: isTextComponent, diff --git a/packages/core/src/resolvable/serialize.ts b/packages/core/src/resolvable/serialize.ts index e101bb89..940f71f9 100644 --- a/packages/core/src/resolvable/serialize.ts +++ b/packages/core/src/resolvable/serialize.ts @@ -63,7 +63,7 @@ const isNormalizedResolvableValue = (value: NormalizedResolvableJsonValue): valu return 'type' in value && (value.type === 'env' || value.type === 'when' || value.type === 'match') } -const isResolvableValueExpression = (value: unknown): value is ResolvableExpression => { +export const isResolvableValueExpression = (value: unknown): value is ResolvableExpression => { return isResolvableExpression(value) && !isResolvableCondition(value) } diff --git a/packages/ios/src/jsx/Text.tsx b/packages/ios/src/jsx/Text.tsx index 702082bf..d4548dee 100644 --- a/packages/ios/src/jsx/Text.tsx +++ b/packages/ios/src/jsx/Text.tsx @@ -1,10 +1,12 @@ +import type { ResolvableExpression } from '@use-voltra/core' import { VoltraTextStyleProp } from '../styles/index.js' import { createVoltraComponent } from './createVoltraComponent.js' import type { TextProps as GeneratedTextProps } from './props/Text.js' -// Update 'style' at this point, so the generated types remain unchanged. -export type TextProps = Omit & { +// Update 'style' and 'children' at this point, so the generated types remain unchanged. +export type TextProps = Omit & { style?: VoltraTextStyleProp + children?: string | number | boolean | ResolvableExpression } export const Text = createVoltraComponent('Text') diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt index fdef0017..5e977b08 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -50,7 +50,7 @@ fun RenderText( resolveAndApplyStyle(element.p, renderContext.sharedStyles).compositeStyle } - val text = extractTextFromNode(element.c) + val text: String = (element.p?.get("text") as? String) ?: extractTextFromNode(element.c) val renderAsBitmap = element.p?.get("renderAsBitmap") as? Boolean ?: false val textStyle = resolvedStyle?.text ?: voltra.styling.TextStyle.Default diff --git a/packages/voltra/ios/ui/Views/VoltraText.swift b/packages/voltra/ios/ui/Views/VoltraText.swift index fb3aad76..fb5dd847 100644 --- a/packages/voltra/ios/ui/Views/VoltraText.swift +++ b/packages/voltra/ios/ui/Views/VoltraText.swift @@ -11,6 +11,11 @@ public struct VoltraText: VoltraView { public var body: some View { let textContent: String = { + // p.txt resolved value (from ResolvableExpression children) takes priority + if let textProp = element.props?["text"]?.stringValue { + return textProp + } + // Backward compat: static string children if let children = element.children, case let .text(text) = children { return text } diff --git a/packages/voltra/src/android/jsx/Text.tsx b/packages/voltra/src/android/jsx/Text.tsx index d62663f0..ff22e2cd 100644 --- a/packages/voltra/src/android/jsx/Text.tsx +++ b/packages/voltra/src/android/jsx/Text.tsx @@ -1,10 +1,12 @@ +import type { ResolvableExpression } from '@use-voltra/core' import { createVoltraComponent } from '../../jsx/createVoltraComponent.js' import { VoltraAndroidTextStyleProp } from '../styles/types.js' import type { TextProps as GeneratedTextProps } from './props/Text.js' -// Update 'style' to use Android text style prop -export type TextProps = Omit & { +// Update 'style' and 'children' to use Android text style prop and support ResolvableExpression. +export type TextProps = Omit & { style?: VoltraAndroidTextStyleProp + children?: string | number | boolean | ResolvableExpression } export const Text = createVoltraComponent('AndroidText') diff --git a/packages/voltra/src/jsx/Text.tsx b/packages/voltra/src/jsx/Text.tsx index 702082bf..d4548dee 100644 --- a/packages/voltra/src/jsx/Text.tsx +++ b/packages/voltra/src/jsx/Text.tsx @@ -1,10 +1,12 @@ +import type { ResolvableExpression } from '@use-voltra/core' import { VoltraTextStyleProp } from '../styles/index.js' import { createVoltraComponent } from './createVoltraComponent.js' import type { TextProps as GeneratedTextProps } from './props/Text.js' -// Update 'style' at this point, so the generated types remain unchanged. -export type TextProps = Omit & { +// Update 'style' and 'children' at this point, so the generated types remain unchanged. +export type TextProps = Omit & { style?: VoltraTextStyleProp + children?: string | number | boolean | ResolvableExpression } export const Text = createVoltraComponent('Text') From e0d6ad71a01d1637357fb04cb947e4061b07592c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 15 Apr 2026 12:01:05 +0200 Subject: [PATCH 5/8] feat: add ControlIf and ControlSwitch --- .../ios/IosResolvablePlaygroundWidget.tsx | 195 ++++++++---------- packages/android/src/jsx/ControlIf.tsx | 12 ++ packages/android/src/jsx/ControlSwitch.tsx | 11 + packages/android/src/jsx/primitives.ts | 2 + packages/android/src/payload/component-ids.ts | 8 +- packages/core/src/payload/short-names.ts | 6 + packages/core/src/renderer/renderer.ts | 51 ++++- packages/core/src/resolvable/normalize.ts | 2 +- packages/core/src/resolvable/public.ts | 8 +- packages/core/src/resolvable/serialize.ts | 13 +- packages/ios/src/jsx/ControlIf.tsx | 12 ++ packages/ios/src/jsx/ControlSwitch.tsx | 11 + packages/ios/src/jsx/primitives.ts | 2 + .../ios/src/jsx/props/AndroidControlIf.ts | 7 + .../ios/src/jsx/props/AndroidControlSwitch.ts | 7 + packages/ios/src/jsx/props/AndroidIf.ts | 7 + packages/ios/src/jsx/props/AndroidMatch.ts | 7 + packages/ios/src/jsx/props/ControlIf.ts | 7 + packages/ios/src/jsx/props/ControlSwitch.ts | 7 + packages/ios/src/jsx/props/If.ts | 7 + packages/ios/src/jsx/props/Switch.ts | 7 + packages/ios/src/payload/component-ids.ts | 4 + .../main/java/voltra/generated/ShortNames.kt | 3 + .../parameters/AndroidControlIfParameters.kt | 20 ++ .../AndroidControlSwitchParameters.kt | 20 ++ .../models/parameters/AndroidIfParameters.kt | 20 ++ .../parameters/AndroidMatchParameters.kt | 20 ++ .../java/voltra/payload/ComponentTypeID.kt | 8 +- .../resolvable/ResolvablePayloadResolver.kt | 22 +- .../resolvable/ResolvableValueEvaluator.kt | 103 +++++++-- packages/voltra/data/components.json | 33 ++- packages/voltra/ios/Package.swift | 18 +- .../voltra/ios/shared/ComponentTypeID.swift | 8 + .../Resolvable/ResolvableValueParser.swift | 28 +-- packages/voltra/ios/shared/ShortNames.swift | 3 + .../voltra/ios/shared/VoltraElement.swift | 21 +- packages/voltra/ios/shared/VoltraNode.swift | 6 + packages/voltra/ios/target/VoltraWidget.swift | 10 +- .../Parameters/ControlIfParameters.swift | 12 ++ .../Parameters/ControlSwitchParameters.swift | 12 ++ .../Generated/Parameters/IfParameters.swift | 12 ++ .../Parameters/SwitchParameters.swift | 12 ++ .../voltra/ios/ui/Views/VoltraControlIf.swift | 28 +++ .../ios/ui/Views/VoltraControlSwitch.swift | 53 +++++ .../android-dynamic-color.node.test.tsx | 8 +- .../src/__tests__/widget-server.node.test.tsx | 8 +- .../src/android/payload/component-ids.ts | 8 +- packages/voltra/src/jsx/ControlIf.tsx | 12 ++ packages/voltra/src/jsx/ControlSwitch.tsx | 11 + packages/voltra/src/jsx/primitives.ts | 2 + .../voltra/src/jsx/props/AndroidControlIf.ts | 7 + .../src/jsx/props/AndroidControlSwitch.ts | 7 + packages/voltra/src/jsx/props/AndroidIf.ts | 7 + packages/voltra/src/jsx/props/AndroidMatch.ts | 7 + packages/voltra/src/jsx/props/ControlIf.ts | 7 + .../voltra/src/jsx/props/ControlSwitch.ts | 7 + packages/voltra/src/jsx/props/If.ts | 7 + packages/voltra/src/jsx/props/Switch.ts | 7 + packages/voltra/src/payload/component-ids.ts | 4 + .../__tests__/control-flow.node.test.ts | 185 +++++++++++++++++ 60 files changed, 972 insertions(+), 187 deletions(-) create mode 100644 packages/android/src/jsx/ControlIf.tsx create mode 100644 packages/android/src/jsx/ControlSwitch.tsx create mode 100644 packages/ios/src/jsx/ControlIf.tsx create mode 100644 packages/ios/src/jsx/ControlSwitch.tsx create mode 100644 packages/ios/src/jsx/props/AndroidControlIf.ts create mode 100644 packages/ios/src/jsx/props/AndroidControlSwitch.ts create mode 100644 packages/ios/src/jsx/props/AndroidIf.ts create mode 100644 packages/ios/src/jsx/props/AndroidMatch.ts create mode 100644 packages/ios/src/jsx/props/ControlIf.ts create mode 100644 packages/ios/src/jsx/props/ControlSwitch.ts create mode 100644 packages/ios/src/jsx/props/If.ts create mode 100644 packages/ios/src/jsx/props/Switch.ts create mode 100644 packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlIfParameters.kt create mode 100644 packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlSwitchParameters.kt create mode 100644 packages/voltra/android/src/main/java/voltra/models/parameters/AndroidIfParameters.kt create mode 100644 packages/voltra/android/src/main/java/voltra/models/parameters/AndroidMatchParameters.kt create mode 100644 packages/voltra/ios/ui/Generated/Parameters/ControlIfParameters.swift create mode 100644 packages/voltra/ios/ui/Generated/Parameters/ControlSwitchParameters.swift create mode 100644 packages/voltra/ios/ui/Generated/Parameters/IfParameters.swift create mode 100644 packages/voltra/ios/ui/Generated/Parameters/SwitchParameters.swift create mode 100644 packages/voltra/ios/ui/Views/VoltraControlIf.swift create mode 100644 packages/voltra/ios/ui/Views/VoltraControlSwitch.swift create mode 100644 packages/voltra/src/jsx/ControlIf.tsx create mode 100644 packages/voltra/src/jsx/ControlSwitch.tsx create mode 100644 packages/voltra/src/jsx/props/AndroidControlIf.ts create mode 100644 packages/voltra/src/jsx/props/AndroidControlSwitch.ts create mode 100644 packages/voltra/src/jsx/props/AndroidIf.ts create mode 100644 packages/voltra/src/jsx/props/AndroidMatch.ts create mode 100644 packages/voltra/src/jsx/props/ControlIf.ts create mode 100644 packages/voltra/src/jsx/props/ControlSwitch.ts create mode 100644 packages/voltra/src/jsx/props/If.ts create mode 100644 packages/voltra/src/jsx/props/Switch.ts create mode 100644 packages/voltra/src/renderer/__tests__/control-flow.node.test.ts diff --git a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx index 28b1fff6..c89787db 100644 --- a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx +++ b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { and, env, eq, when, Voltra, type WidgetVariants } from '@use-voltra/ios' +import { env, eq, when, Voltra, type WidgetVariants } from '@use-voltra/ios' type WidgetSize = 'small' | 'medium' @@ -82,114 +82,101 @@ const IosResolvablePlaygroundBody = ({ size }: { size: WidgetSize }) => { - - A - - - F - - + + A + + + ), + fullColor: ( + + + F + + + ), + default: ( + + + V + + + ), }} - > - V - + /> - - Y - - + + N + + + } > - N - + + + Y + + + diff --git a/packages/android/src/jsx/ControlIf.tsx b/packages/android/src/jsx/ControlIf.tsx new file mode 100644 index 00000000..fc16404e --- /dev/null +++ b/packages/android/src/jsx/ControlIf.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' +import type { ResolvableCondition } from '@use-voltra/core' +import { createVoltraComponent } from './createVoltraComponent.js' + +export type ControlIfProps = { + id?: string + condition: ResolvableCondition + children?: ReactNode + else?: ReactNode +} + +export const ControlIf = createVoltraComponent('AndroidControlIf') diff --git a/packages/android/src/jsx/ControlSwitch.tsx b/packages/android/src/jsx/ControlSwitch.tsx new file mode 100644 index 00000000..8e0faec6 --- /dev/null +++ b/packages/android/src/jsx/ControlSwitch.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' +import type { ResolvableValue } from '@use-voltra/core' +import { createVoltraComponent } from './createVoltraComponent.js' + +export type ControlSwitchProps = { + id?: string + value: ResolvableValue + cases: Record & { default?: ReactNode } +} + +export const ControlSwitch = createVoltraComponent('AndroidControlSwitch') diff --git a/packages/android/src/jsx/primitives.ts b/packages/android/src/jsx/primitives.ts index 9f683f60..6038bd7b 100644 --- a/packages/android/src/jsx/primitives.ts +++ b/packages/android/src/jsx/primitives.ts @@ -1,3 +1,5 @@ +export * from './ControlIf.js' +export * from './ControlSwitch.js' export * from './AreaMark.js' export * from './BarMark.js' export * from './Box.js' diff --git a/packages/android/src/payload/component-ids.ts b/packages/android/src/payload/component-ids.ts index 8d47d5e3..7c65f53f 100644 --- a/packages/android/src/payload/component-ids.ts +++ b/packages/android/src/payload/component-ids.ts @@ -28,7 +28,9 @@ export const ANDROID_COMPONENT_NAME_TO_ID: Record = { AndroidSquareIconButton: 17, AndroidText: 18, AndroidTitleBar: 19, - AndroidChart: 20, + AndroidControlIf: 20, + AndroidControlSwitch: 21, + AndroidChart: 22, } /** @@ -55,7 +57,9 @@ export const ANDROID_COMPONENT_ID_TO_NAME: Record = { 17: 'AndroidSquareIconButton', 18: 'AndroidText', 19: 'AndroidTitleBar', - 20: 'AndroidChart', + 20: 'AndroidControlIf', + 21: 'AndroidControlSwitch', + 22: 'AndroidChart', } /** diff --git a/packages/core/src/payload/short-names.ts b/packages/core/src/payload/short-names.ts index 7beec772..06937299 100644 --- a/packages/core/src/payload/short-names.ts +++ b/packages/core/src/payload/short-names.ts @@ -24,11 +24,13 @@ export const NAME_TO_SHORT: Record = { borderWidth: 'bw', bottom: 'b', buttonStyle: 'bs', + cases: 'cas', chartScrollableAxes: 'csa', checked: 'chk', clipped: 'clip', color: 'c', colors: 'cls', + condition: 'cond', contentAlignment: 'ca', contentColor: 'cc', contentDescription: 'cdesc', @@ -41,6 +43,7 @@ export const NAME_TO_SHORT: Record = { direction: 'dir', dither: 'dth', durationMs: 'dur', + else: 'els', enabled: 'en', endAtMs: 'end', endPoint: 'ep', @@ -187,11 +190,13 @@ export const SHORT_TO_NAME: Record = { bw: 'borderWidth', b: 'bottom', bs: 'buttonStyle', + cas: 'cases', csa: 'chartScrollableAxes', chk: 'checked', clip: 'clipped', c: 'color', cls: 'colors', + cond: 'condition', ca: 'contentAlignment', cc: 'contentColor', cdesc: 'contentDescription', @@ -204,6 +209,7 @@ export const SHORT_TO_NAME: Record = { dir: 'direction', dth: 'dither', dur: 'durationMs', + els: 'else', en: 'enabled', end: 'endAtMs', ep: 'endPoint', diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 5b35eaaf..82e2e541 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -25,7 +25,13 @@ import { import { isVoltraComponent } from '../jsx/createVoltraComponent.js' import { shorten } from '../payload/short-names.js' -import { isResolvableValueExpression, serializeResolvablePropValue, serializeStyleObject } from '../resolvable/serialize.js' +import type { ResolvableCondition } from '../resolvable/public.js' +import { + isResolvableValueExpression, + serializeCondition, + serializeResolvablePropValue, + serializeStyleObject, +} from '../resolvable/serialize.js' import { VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '../types.js' import { ContextRegistry, getContextRegistry } from './context-registry.js' import { getHooksDispatcher, getReactCurrentDispatcher } from './dispatcher.js' @@ -208,6 +214,8 @@ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext) const { children, ...parameters } = child.props as { children?: ReactNode; [key: string]: unknown } const isTextComponent = child.type === 'Text' || child.type === 'AndroidText' + const isIfComponent = child.type === 'ControlIf' || child.type === 'AndroidControlIf' + const isMatchComponent = child.type === 'ControlSwitch' || child.type === 'AndroidControlSwitch' // Short-circuit: ResolvableExpression as children of a Text component is serialized // via p.txt (wire key for "text") so the native resolver handles it at render time. @@ -228,6 +236,47 @@ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext) ...context, inStringOnlyContext: isTextComponent, } + + if (isIfComponent) { + const id = typeof parameters.id === 'string' ? parameters.id : undefined + const condition = parameters.condition as ResolvableCondition + const elseNode = parameters.else as ReactNode | undefined + const thenRendered = children !== null && children !== undefined ? renderNode(children, childContext) : [] + const elseRendered = + elseNode !== null && elseNode !== undefined ? renderNode(elseNode, childContext) : undefined + const props: Record = { + [shorten('condition')]: serializeCondition(condition), + } + if (elseRendered !== undefined) { + props[shorten('else')] = elseRendered + } + return { + t: context.componentRegistry.getComponentId(child.type), + ...(id ? { i: id } : {}), + c: thenRendered, + p: props, + } + } + + if (isMatchComponent) { + const id = typeof parameters.id === 'string' ? parameters.id : undefined + const value = parameters.value + const cases = parameters.cases as Record + const serializedValue = serializeResolvablePropValue(value) + const serializedCases: Record = {} + for (const [caseKey, caseNode] of Object.entries(cases)) { + serializedCases[caseKey] = renderNode(caseNode as ReactNode, childContext) + } + return { + t: context.componentRegistry.getComponentId(child.type), + ...(id ? { i: id } : {}), + p: { + [shorten('value')]: serializedValue, + [shorten('cases')]: serializedCases, + }, + } + } + const renderedChildren = children !== null && children !== undefined ? renderNode(children, childContext) : isTextComponent ? '' : [] diff --git a/packages/core/src/resolvable/normalize.ts b/packages/core/src/resolvable/normalize.ts index 07bb4d33..e51bdf00 100644 --- a/packages/core/src/resolvable/normalize.ts +++ b/packages/core/src/resolvable/normalize.ts @@ -60,7 +60,7 @@ const normalizeJsonLike = (value: unknown): NormalizedResolvableJsonValue => { throw new Error(`[Voltra] Unsupported resolvable payload value of type "${typeof value}".`) } -const normalizeCondition = (condition: ResolvableCondition): NormalizedResolvableCondition => { +export const normalizeCondition = (condition: ResolvableCondition): NormalizedResolvableCondition => { switch (condition.kind) { case 'eq': case 'ne': diff --git a/packages/core/src/resolvable/public.ts b/packages/core/src/resolvable/public.ts index 6962e54f..77a81fc8 100644 --- a/packages/core/src/resolvable/public.ts +++ b/packages/core/src/resolvable/public.ts @@ -62,10 +62,10 @@ export type ResolvableExpression = export type ResolvableValue = [T] extends [ResolvablePrimitive] ? T | ResolvableExpression : [T] extends [readonly unknown[]] - ? { [K in keyof T]: ResolvableValue } | ResolvableExpression - : [T] extends [object] - ? { [K in keyof T]: ResolvableValue } | ResolvableExpression - : T | ResolvableExpression + ? { [K in keyof T]: ResolvableValue } | ResolvableExpression + : [T] extends [object] + ? { [K in keyof T]: ResolvableValue } | ResolvableExpression + : T | ResolvableExpression const createResolvable = ( kind: TKind, diff --git a/packages/core/src/resolvable/serialize.ts b/packages/core/src/resolvable/serialize.ts index 940f71f9..c2a3ccb5 100644 --- a/packages/core/src/resolvable/serialize.ts +++ b/packages/core/src/resolvable/serialize.ts @@ -19,8 +19,13 @@ import type { NormalizedResolvableValue, } from './internal.js' import { isResolvableExpression } from './public.js' -import type { ResolvableExpression } from './public.js' -import { isResolvableCondition, normalizeResolvableJsonValue, normalizeResolvableValue } from './normalize.js' +import type { ResolvableCondition, ResolvableExpression } from './public.js' +import { + isResolvableCondition, + normalizeCondition, + normalizeResolvableJsonValue, + normalizeResolvableValue, +} from './normalize.js' const serializeConditionTuple = (condition: NormalizedResolvableCondition): VoltraResolvableConditionTuple => { switch (condition.type) { @@ -109,6 +114,10 @@ const serializeNormalizedJsonValue = (value: NormalizedResolvableJsonValue): Vol return serialized } +export const serializeCondition = (condition: ResolvableCondition): VoltraResolvableConditionTuple => { + return serializeConditionTuple(normalizeCondition(condition)) +} + export const serializeResolvablePropValue = (value: unknown): VoltraPropValue => { const normalized = isResolvableValueExpression(value) ? normalizeResolvableValue(value) diff --git a/packages/ios/src/jsx/ControlIf.tsx b/packages/ios/src/jsx/ControlIf.tsx new file mode 100644 index 00000000..b2d2abe2 --- /dev/null +++ b/packages/ios/src/jsx/ControlIf.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' +import type { ResolvableCondition } from '@use-voltra/core' +import { createVoltraComponent } from './createVoltraComponent.js' + +export type ControlIfProps = { + id?: string + condition: ResolvableCondition + children?: ReactNode + else?: ReactNode +} + +export const ControlIf = createVoltraComponent('ControlIf') diff --git a/packages/ios/src/jsx/ControlSwitch.tsx b/packages/ios/src/jsx/ControlSwitch.tsx new file mode 100644 index 00000000..e5a1dffa --- /dev/null +++ b/packages/ios/src/jsx/ControlSwitch.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' +import type { ResolvableValue } from '@use-voltra/core' +import { createVoltraComponent } from './createVoltraComponent.js' + +export type ControlSwitchProps = { + id?: string + value: ResolvableValue + cases: Record & { default?: ReactNode } +} + +export const ControlSwitch = createVoltraComponent('ControlSwitch') diff --git a/packages/ios/src/jsx/primitives.ts b/packages/ios/src/jsx/primitives.ts index 519f15de..d35cd3e4 100644 --- a/packages/ios/src/jsx/primitives.ts +++ b/packages/ios/src/jsx/primitives.ts @@ -1,3 +1,5 @@ +export * from './ControlIf.js' +export * from './ControlSwitch.js' export * from './AreaMark.js' export * from './BarMark.js' export * from './Button.js' diff --git a/packages/ios/src/jsx/props/AndroidControlIf.ts b/packages/ios/src/jsx/props/AndroidControlIf.ts new file mode 100644 index 00000000..d50c382c --- /dev/null +++ b/packages/ios/src/jsx/props/AndroidControlIf.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidControlIfProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/AndroidControlSwitch.ts b/packages/ios/src/jsx/props/AndroidControlSwitch.ts new file mode 100644 index 00000000..a10a23c8 --- /dev/null +++ b/packages/ios/src/jsx/props/AndroidControlSwitch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidControlSwitchProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/AndroidIf.ts b/packages/ios/src/jsx/props/AndroidIf.ts new file mode 100644 index 00000000..2ced4504 --- /dev/null +++ b/packages/ios/src/jsx/props/AndroidIf.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidIfProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/AndroidMatch.ts b/packages/ios/src/jsx/props/AndroidMatch.ts new file mode 100644 index 00000000..0da148ca --- /dev/null +++ b/packages/ios/src/jsx/props/AndroidMatch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidMatchProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/ControlIf.ts b/packages/ios/src/jsx/props/ControlIf.ts new file mode 100644 index 00000000..2688208a --- /dev/null +++ b/packages/ios/src/jsx/props/ControlIf.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type ControlIfProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/ControlSwitch.ts b/packages/ios/src/jsx/props/ControlSwitch.ts new file mode 100644 index 00000000..d5889e9d --- /dev/null +++ b/packages/ios/src/jsx/props/ControlSwitch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type ControlSwitchProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/If.ts b/packages/ios/src/jsx/props/If.ts new file mode 100644 index 00000000..6391b018 --- /dev/null +++ b/packages/ios/src/jsx/props/If.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type IfProps = VoltraBaseProps diff --git a/packages/ios/src/jsx/props/Switch.ts b/packages/ios/src/jsx/props/Switch.ts new file mode 100644 index 00000000..62ae0a59 --- /dev/null +++ b/packages/ios/src/jsx/props/Switch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type SwitchProps = VoltraBaseProps diff --git a/packages/ios/src/payload/component-ids.ts b/packages/ios/src/payload/component-ids.ts index e7e0fdf7..4170f35d 100644 --- a/packages/ios/src/payload/component-ids.ts +++ b/packages/ios/src/payload/component-ids.ts @@ -30,6 +30,8 @@ export const COMPONENT_NAME_TO_ID: Record = { Link: 19, View: 20, Chart: 21, + ControlIf: 22, + ControlSwitch: 23, } /** @@ -58,6 +60,8 @@ export const COMPONENT_ID_TO_NAME: Record = { 19: 'Link', 20: 'View', 21: 'Chart', + 22: 'ControlIf', + 23: 'ControlSwitch', } /** diff --git a/packages/voltra/android/src/main/java/voltra/generated/ShortNames.kt b/packages/voltra/android/src/main/java/voltra/generated/ShortNames.kt index 0d7ef2e7..64bc611c 100644 --- a/packages/voltra/android/src/main/java/voltra/generated/ShortNames.kt +++ b/packages/voltra/android/src/main/java/voltra/generated/ShortNames.kt @@ -31,11 +31,13 @@ object ShortNames { "bw" to "borderWidth", "b" to "bottom", "bs" to "buttonStyle", + "cas" to "cases", "csa" to "chartScrollableAxes", "chk" to "checked", "clip" to "clipped", "c" to "color", "cls" to "colors", + "cond" to "condition", "ca" to "contentAlignment", "cc" to "contentColor", "cdesc" to "contentDescription", @@ -48,6 +50,7 @@ object ShortNames { "dir" to "direction", "dth" to "dither", "dur" to "durationMs", + "els" to "else", "en" to "enabled", "end" to "endAtMs", "ep" to "endPoint", diff --git a/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlIfParameters.kt b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlIfParameters.kt new file mode 100644 index 00000000..e5908077 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlIfParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidControlIfParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidControlIf component + * Control-flow component: renders children when condition is true, else branch otherwise + */ +@Serializable +data class AndroidControlIfParameters( + /** Dummy parameter to satisfy data class requirements */ + val _dummy: Unit = Unit, +) diff --git a/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlSwitchParameters.kt b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlSwitchParameters.kt new file mode 100644 index 00000000..276ca300 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidControlSwitchParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidControlSwitchParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidControlSwitch component + * Control-flow component: selects a child branch by matching a value against cases + */ +@Serializable +data class AndroidControlSwitchParameters( + /** Dummy parameter to satisfy data class requirements */ + val _dummy: Unit = Unit, +) diff --git a/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidIfParameters.kt b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidIfParameters.kt new file mode 100644 index 00000000..3f367998 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidIfParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidIfParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidIf component + * Control-flow component: renders children when condition is true, else branch otherwise + */ +@Serializable +data class AndroidIfParameters( + /** Dummy parameter to satisfy data class requirements */ + val _dummy: Unit = Unit, +) diff --git a/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidMatchParameters.kt b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidMatchParameters.kt new file mode 100644 index 00000000..e2c1142a --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/models/parameters/AndroidMatchParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidMatchParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidMatch component + * Control-flow component: selects a child branch by matching a value against cases + */ +@Serializable +data class AndroidMatchParameters( + /** Dummy parameter to satisfy data class requirements */ + val _dummy: Unit = Unit, +) diff --git a/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt b/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt index 2582c9cc..dd6271a6 100644 --- a/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt +++ b/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt @@ -32,7 +32,9 @@ object ComponentTypeID { const val SQUARE_ICON_BUTTON = 17 const val TEXT = 18 const val TITLE_BAR = 19 - const val CHART = 20 + const val CONTROL_IF = 20 + const val CONTROL_SWITCH = 21 + const val CHART = 22 /** * Get component name from numeric ID @@ -59,7 +61,9 @@ object ComponentTypeID { 17 -> "AndroidSquareIconButton" 18 -> "AndroidText" 19 -> "AndroidTitleBar" - 20 -> "AndroidChart" + 20 -> "AndroidControlIf" + 21 -> "AndroidControlSwitch" + 22 -> "AndroidChart" else -> null } } diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt index 8d640571..11ea41f0 100644 --- a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvablePayloadResolver.kt @@ -26,13 +26,19 @@ object ResolvablePayloadResolver { environment: ResolvableRuntimeEnvironment, ): VoltraNode = when (node) { - is VoltraNode.Element -> + is VoltraNode.Element -> { VoltraNode.Element( resolveElement(node.element, environment), ) - is VoltraNode.Array -> + } + + is VoltraNode.Array -> { VoltraNode.Array(node.elements.map { resolveNode(it, environment) }) - else -> node + } + + else -> { + node + } } private fun resolveElement( @@ -69,7 +75,13 @@ object ResolvablePayloadResolver { resolveMap(m, environment) } } - is List<*> -> value.map { resolveValue(it, environment) } - else -> value + + is List<*> -> { + value.map { resolveValue(it, environment) } + } + + else -> { + value + } } } diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt index b48dfe9f..d05ec264 100644 --- a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt @@ -91,8 +91,14 @@ internal object ResolvableValueEvaluator { private fun parse(value: Any?): Parsed = when (value) { - null, is String, is Boolean, is Number -> Parsed.Literal(value) - is List<*> -> Parsed.ArrayVal(value.map { parse(it) }) + null, is String, is Boolean, is Number -> { + Parsed.Literal(value) + } + + is List<*> -> { + Parsed.ArrayVal(value.map { parse(it) }) + } + is Map<*, *> -> { @Suppress("UNCHECKED_CAST") val map = value as Map @@ -106,7 +112,10 @@ internal object ResolvableValueEvaluator { Parsed.Obj(parsed) } } - else -> Parsed.Literal(value) + + else -> { + Parsed.Literal(value) + } } private fun parseWrappedExpression(map: Map): ResolvableExpr { @@ -124,6 +133,7 @@ internal object ResolvableValueEvaluator { val envId = tuple[1].asOpcodeInt() ?: error("Invalid environment id") ResolvableExpr.Env(envId) } + ResolvableValueOpcode.WHEN -> { require(tuple.size == 4) { "Invalid when tuple" } ResolvableExpr.When( @@ -132,6 +142,7 @@ internal object ResolvableValueEvaluator { elseValue = parse(tuple[3]), ) } + ResolvableValueOpcode.MATCH -> { require(tuple.size == 3) { "Invalid match tuple" } val casesRaw = tuple[2] @@ -139,7 +150,9 @@ internal object ResolvableValueEvaluator { @Suppress("UNCHECKED_CAST") val casesMap = casesRaw as Map val parsedCases = casesMap.mapValues { (_, v) -> parse(v) } - require(ResolvableWireKey.DEFAULT_CASE in parsedCases) { "Resolvable match expression is missing a default case" } + require( + ResolvableWireKey.DEFAULT_CASE in parsedCases, + ) { "Resolvable match expression is missing a default case" } ResolvableExpr.Match(value = parse(tuple[1]), cases = parsedCases) } } @@ -158,26 +171,31 @@ internal object ResolvableValueEvaluator { require(tuple.size == 3) { "Invalid eq tuple" } Condition.Eq(parse(tuple[1]), parse(tuple[2])) } + ResolvableConditionOpcode.NE -> { require(tuple.size == 3) { "Invalid ne tuple" } Condition.Ne(parse(tuple[1]), parse(tuple[2])) } + ResolvableConditionOpcode.AND -> { require(tuple.size == 2 && tuple[1] is List<*>) { "Invalid and tuple" } @Suppress("UNCHECKED_CAST") val items = tuple[1] as List Condition.And(items.map { parseCondition(it) }) } + ResolvableConditionOpcode.OR -> { require(tuple.size == 2 && tuple[1] is List<*>) { "Invalid or tuple" } @Suppress("UNCHECKED_CAST") val items = tuple[1] as List Condition.Or(items.map { parseCondition(it) }) } + ResolvableConditionOpcode.NOT -> { require(tuple.size == 2) { "Invalid not tuple" } Condition.Not(parseCondition(tuple[1])) } + ResolvableConditionOpcode.IN_LIST -> { require(tuple.size == 3 && tuple[2] is List<*>) { "Invalid inList tuple" } @Suppress("UNCHECKED_CAST") @@ -203,13 +221,18 @@ internal object ResolvableValueEvaluator { environment: ResolvableRuntimeEnvironment, ): Any? = when (expr) { - is ResolvableExpr.Env -> environment.envValue(expr.envId) - is ResolvableExpr.When -> + is ResolvableExpr.Env -> { + environment.envValue(expr.envId) + } + + is ResolvableExpr.When -> { if (evaluate(expr.condition, environment)) { evaluate(expr.thenValue, environment) } else { evaluate(expr.elseValue, environment) } + } + is ResolvableExpr.Match -> { val resolved = evaluate(expr.value, environment) val key = matchCaseKey(resolved) @@ -228,19 +251,32 @@ internal object ResolvableValueEvaluator { environment: ResolvableRuntimeEnvironment, ): Boolean = when (condition) { - is Condition.Eq -> + is Condition.Eq -> { jsonEquals( evaluate(condition.left, environment), evaluate(condition.right, environment), ) - is Condition.Ne -> + } + + is Condition.Ne -> { !jsonEquals( evaluate(condition.left, environment), evaluate(condition.right, environment), ) - is Condition.And -> condition.conditions.all { evaluate(it, environment) } - is Condition.Or -> condition.conditions.any { evaluate(it, environment) } - is Condition.Not -> !evaluate(condition.condition, environment) + } + + is Condition.And -> { + condition.conditions.all { evaluate(it, environment) } + } + + is Condition.Or -> { + condition.conditions.any { evaluate(it, environment) } + } + + is Condition.Not -> { + !evaluate(condition.condition, environment) + } + is Condition.InList -> { val resolved = evaluate(condition.value, environment) condition.values.any { jsonEquals(evaluate(it, environment), resolved) } @@ -249,8 +285,14 @@ internal object ResolvableValueEvaluator { private fun matchCaseKey(value: Any?): String = when (value) { - null -> "null" - is Boolean -> if (value) "true" else "false" + null -> { + "null" + } + + is Boolean -> { + if (value) "true" else "false" + } + is Number -> { val d = value.toDouble() if (d.isFinite() && floor(d) == d && d >= Long.MIN_VALUE.toDouble() && d <= Long.MAX_VALUE.toDouble()) { @@ -259,10 +301,22 @@ internal object ResolvableValueEvaluator { d.toString() } } - is String -> value - is List<*> -> value.toString() - is Map<*, *> -> value.toString() - else -> value.toString() + + is String -> { + value + } + + is List<*> -> { + value.toString() + } + + is Map<*, *> -> { + value.toString() + } + + else -> { + value.toString() + } } private fun jsonEquals( @@ -278,14 +332,23 @@ internal object ResolvableValueEvaluator { private fun containsResolvable(value: Any?): Boolean = when (value) { - null, is String, is Boolean, is Number -> false + null, is String, is Boolean, is Number -> { + false + } + is Map<*, *> -> { @Suppress("UNCHECKED_CAST") val m = value as Map m.containsKey(ResolvableWireKey.SENTINEL) || m.values.any { containsResolvable(it) } } - is List<*> -> value.any { containsResolvable(it) } - else -> false + + is List<*> -> { + value.any { containsResolvable(it) } + } + + else -> { + false + } } private fun Any?.asOpcodeInt(): Int? = diff --git a/packages/voltra/data/components.json b/packages/voltra/data/components.json index 64dddb3e..81d47c9c 100644 --- a/packages/voltra/data/components.json +++ b/packages/voltra/data/components.json @@ -157,7 +157,10 @@ "xAxisGridStyle": "xgs", "xAxisVisibility": "xav", "yAxisGridStyle": "ygs", - "yAxisVisibility": "yav" + "yAxisVisibility": "yav", + "condition": "cond", + "else": "els", + "cases": "cas" }, "styleProperties": [ "padding", @@ -1297,6 +1300,34 @@ } } }, + { + "name": "ControlIf", + "description": "Control-flow component: renders children when condition is true, else branch otherwise", + "swiftAvailability": "iOS 13.0, macOS 10.15", + "hasChildren": true, + "parameters": {} + }, + { + "name": "ControlSwitch", + "description": "Control-flow component: selects a child branch by matching a value against cases", + "swiftAvailability": "iOS 13.0, macOS 10.15", + "parameters": {} + }, + { + "name": "AndroidControlIf", + "description": "Control-flow component: renders children when condition is true, else branch otherwise", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "hasChildren": true, + "parameters": {} + }, + { + "name": "AndroidControlSwitch", + "description": "Control-flow component: selects a child branch by matching a value against cases", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": {} + }, { "name": "AndroidChart", "description": "Android charts component for data visualization", diff --git a/packages/voltra/ios/Package.swift b/packages/voltra/ios/Package.swift index e82fa36d..4d27025e 100644 --- a/packages/voltra/ios/Package.swift +++ b/packages/voltra/ios/Package.swift @@ -38,15 +38,15 @@ let package = Package( "VoltraPersistentEventQueue.swift", ], sources: [ - "JSONValue.swift", - "Resolvable/ResolvableConstants.swift", - "Resolvable/ResolvableEnvironment.swift", - "Resolvable/ResolvableValue.swift", - "Resolvable/ResolvableValueEvaluator.swift", - "Resolvable/ResolvableValueParser.swift", - "VoltraPayloadMigrator.swift", - "VoltraRegion.swift", - "ComponentTypeID.swift", + "JSONValue.swift", + "Resolvable/ResolvableConstants.swift", + "Resolvable/ResolvableEnvironment.swift", + "Resolvable/ResolvableValue.swift", + "Resolvable/ResolvableValueEvaluator.swift", + "Resolvable/ResolvableValueParser.swift", + "VoltraPayloadMigrator.swift", + "VoltraRegion.swift", + "ComponentTypeID.swift", ] ), .testTarget( diff --git a/packages/voltra/ios/shared/ComponentTypeID.swift b/packages/voltra/ios/shared/ComponentTypeID.swift index 7582243f..1e131c4d 100644 --- a/packages/voltra/ios/shared/ComponentTypeID.swift +++ b/packages/voltra/ios/shared/ComponentTypeID.swift @@ -32,6 +32,8 @@ public enum ComponentTypeID: Int, Codable { case LINK = 19 case VIEW = 20 case CHART = 21 + case CONTROL_IF = 22 + case CONTROL_SWITCH = 23 /// Get the component name string for this ID public var componentName: String { @@ -80,6 +82,10 @@ public enum ComponentTypeID: Int, Codable { return "View" case .CHART: return "Chart" + case .CONTROL_IF: + return "ControlIf" + case .CONTROL_SWITCH: + return "ControlSwitch" } } @@ -110,6 +116,8 @@ public enum ComponentTypeID: Int, Codable { case "Link": self = .LINK case "View": self = .VIEW case "Chart": self = .CHART + case "ControlIf": self = .CONTROL_IF + case "ControlSwitch": self = .CONTROL_SWITCH default: return nil } diff --git a/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift b/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift index d4ca2e2f..68cde48b 100644 --- a/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift +++ b/packages/voltra/ios/shared/Resolvable/ResolvableValueParser.swift @@ -6,10 +6,10 @@ public enum ResolvableValueParser { case .null, .bool, .int, .double, .string: return .literal(value) case let .array(items): - return .array(try items.map(parse)) + return try .array(items.map(parse)) case let .object(object): if object.keys.contains(ResolvableWireKey.sentinel) { - return .expression(try parseWrappedExpression(object)) + return try .expression(parseWrappedExpression(object)) } var parsed: [String: ResolvableJSONValue] = [:] @@ -46,10 +46,10 @@ public enum ResolvableValueParser { guard tuple.count == 4 else { throw ResolvableError.invalidTuple(tupleValue) } - return .when( - condition: try parseCondition(tuple[1]), - thenValue: try parse(tuple[2]), - elseValue: try parse(tuple[3]) + return try .when( + condition: parseCondition(tuple[1]), + thenValue: parse(tuple[2]), + elseValue: parse(tuple[3]) ) case .match: guard tuple.count == 3 else { @@ -68,11 +68,11 @@ public enum ResolvableValueParser { throw ResolvableError.missingDefaultCase } - return .match(value: try parse(tuple[1]), cases: parsedCases) + return try .match(value: parse(tuple[1]), cases: parsedCases) } } - private static func parseCondition(_ value: JSONValue) throws -> ResolvableCondition { + static func parseCondition(_ value: JSONValue) throws -> ResolvableCondition { guard case let .array(tuple) = value, let opcodeValue = tuple.first?.intValue else { throw ResolvableError.invalidConditionTuple(value) } @@ -86,32 +86,32 @@ public enum ResolvableValueParser { guard tuple.count == 3 else { throw ResolvableError.invalidConditionTuple(value) } - return .eq(try parse(tuple[1]), try parse(tuple[2])) + return try .eq(parse(tuple[1]), parse(tuple[2])) case .ne: guard tuple.count == 3 else { throw ResolvableError.invalidConditionTuple(value) } - return .ne(try parse(tuple[1]), try parse(tuple[2])) + return try .ne(parse(tuple[1]), parse(tuple[2])) case .and: guard tuple.count == 2, case let .array(items) = tuple[1] else { throw ResolvableError.invalidConditionTuple(value) } - return .and(try items.map(parseCondition)) + return try .and(items.map(parseCondition)) case .or: guard tuple.count == 2, case let .array(items) = tuple[1] else { throw ResolvableError.invalidConditionTuple(value) } - return .or(try items.map(parseCondition)) + return try .or(items.map(parseCondition)) case .not: guard tuple.count == 2 else { throw ResolvableError.invalidConditionTuple(value) } - return .not(try parseCondition(tuple[1])) + return try .not(parseCondition(tuple[1])) case .inList: guard tuple.count == 3, case let .array(items) = tuple[2] else { throw ResolvableError.invalidConditionTuple(value) } - return .inList(try parse(tuple[1]), try items.map(parse)) + return try .inList(parse(tuple[1]), items.map(parse)) } } } diff --git a/packages/voltra/ios/shared/ShortNames.swift b/packages/voltra/ios/shared/ShortNames.swift index 7aa94116..69b0d70e 100644 --- a/packages/voltra/ios/shared/ShortNames.swift +++ b/packages/voltra/ios/shared/ShortNames.swift @@ -28,11 +28,13 @@ public enum ShortNames { "bw": "borderWidth", "b": "bottom", "bs": "buttonStyle", + "cas": "cases", "csa": "chartScrollableAxes", "chk": "checked", "clip": "clipped", "c": "color", "cls": "colors", + "cond": "condition", "ca": "contentAlignment", "cc": "contentColor", "cdesc": "contentDescription", @@ -45,6 +47,7 @@ public enum ShortNames { "dir": "direction", "dth": "dither", "dur": "durationMs", + "els": "else", "en": "enabled", "end": "endAtMs", "ep": "endPoint", diff --git a/packages/voltra/ios/shared/VoltraElement.swift b/packages/voltra/ios/shared/VoltraElement.swift index 950c3a5b..ed9f4f77 100644 --- a/packages/voltra/ios/shared/VoltraElement.swift +++ b/packages/voltra/ios/shared/VoltraElement.swift @@ -88,7 +88,7 @@ public struct VoltraElement: Hashable { let stylesheet = stylesheet, index >= 0, index < stylesheet.count { - styleDict = stylesheet[index] + styleDict = stylesheet[index] } // Handle inline style (object) else if let objectValue = styleValue.objectValue { @@ -172,6 +172,25 @@ public struct VoltraElement: Hashable { return VoltraNode(from: propValue, stylesheet: stylesheet, sharedElements: sharedElements) } + /// Get a raw (unresolved) prop JSON value by full or short name. + /// Use this when you need to parse the raw wire format (e.g., condition tuples, case branches) + /// before applying any resolvable-value resolution. + func rawPropJSON(_ propName: String) -> JSONValue? { + guard let props = _props else { return nil } + if let value = props[propName] { return value } + for (key, value) in props where ShortNames.expand(key) == propName { + return value + } + return nil + } + + /// Get a component prop without pre-resolving resolvable values. + /// Child elements will resolve their own `$rv` values via `VoltraElementView`. + func rawComponentProp(_ propName: String) -> VoltraNode { + guard let propValue = rawPropJSON(propName) else { return .empty } + return VoltraNode(from: propValue, stylesheet: stylesheet, sharedElements: sharedElements) + } + /// Decode parameters from props public func parameters(_: T.Type) -> T { guard let props = props else { diff --git a/packages/voltra/ios/shared/VoltraNode.swift b/packages/voltra/ios/shared/VoltraNode.swift index d7732d08..a4fe4b1b 100644 --- a/packages/voltra/ios/shared/VoltraNode.swift +++ b/packages/voltra/ios/shared/VoltraNode.swift @@ -195,6 +195,12 @@ struct VoltraElementView: View { EmptyView() } + case "ControlIf": + VoltraControlIf(resolvedElement) + + case "ControlSwitch": + VoltraControlSwitch(resolvedElement) + default: EmptyView() } diff --git a/packages/voltra/ios/target/VoltraWidget.swift b/packages/voltra/ios/target/VoltraWidget.swift index b5eb4ff0..538060fc 100644 --- a/packages/voltra/ios/target/VoltraWidget.swift +++ b/packages/voltra/ios/target/VoltraWidget.swift @@ -47,11 +47,11 @@ public struct VoltraWidget: Widget { activityId: context.activityID, resolvableEnvironment: .init() ) - .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) - .voltraIfLet(context.state.activityBackgroundTint) { view, tint in - let color = JSColorParser.parse(tint) - view.activityBackgroundTint(color) - } + .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes)) + .voltraIfLet(context.state.activityBackgroundTint) { view, tint in + let color = JSColorParser.parse(tint) + view.activityBackgroundTint(color) + } } dynamicIsland: { context in dynamicIslandContent(context: context) } diff --git a/packages/voltra/ios/ui/Generated/Parameters/ControlIfParameters.swift b/packages/voltra/ios/ui/Generated/Parameters/ControlIfParameters.swift new file mode 100644 index 00000000..f271582e --- /dev/null +++ b/packages/voltra/ios/ui/Generated/Parameters/ControlIfParameters.swift @@ -0,0 +1,12 @@ +// +// ControlIfParameters.swift + +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import Foundation + +/// Parameters for ControlIf component +/// Control-flow component: renders children when condition is true, else branch otherwise +public struct ControlIfParameters: ComponentParameters {} diff --git a/packages/voltra/ios/ui/Generated/Parameters/ControlSwitchParameters.swift b/packages/voltra/ios/ui/Generated/Parameters/ControlSwitchParameters.swift new file mode 100644 index 00000000..3938b733 --- /dev/null +++ b/packages/voltra/ios/ui/Generated/Parameters/ControlSwitchParameters.swift @@ -0,0 +1,12 @@ +// +// ControlSwitchParameters.swift + +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import Foundation + +/// Parameters for ControlSwitch component +/// Control-flow component: selects a child branch by matching a value against cases +public struct ControlSwitchParameters: ComponentParameters {} diff --git a/packages/voltra/ios/ui/Generated/Parameters/IfParameters.swift b/packages/voltra/ios/ui/Generated/Parameters/IfParameters.swift new file mode 100644 index 00000000..2f21361f --- /dev/null +++ b/packages/voltra/ios/ui/Generated/Parameters/IfParameters.swift @@ -0,0 +1,12 @@ +// +// IfParameters.swift + +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import Foundation + +/// Parameters for If component +/// Control-flow component: renders children when condition is true, else branch otherwise +public struct IfParameters: ComponentParameters {} diff --git a/packages/voltra/ios/ui/Generated/Parameters/SwitchParameters.swift b/packages/voltra/ios/ui/Generated/Parameters/SwitchParameters.swift new file mode 100644 index 00000000..56f98350 --- /dev/null +++ b/packages/voltra/ios/ui/Generated/Parameters/SwitchParameters.swift @@ -0,0 +1,12 @@ +// +// SwitchParameters.swift + +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import Foundation + +/// Parameters for Switch component +/// Control-flow component: selects a child branch by matching a value against cases +public struct SwitchParameters: ComponentParameters {} diff --git a/packages/voltra/ios/ui/Views/VoltraControlIf.swift b/packages/voltra/ios/ui/Views/VoltraControlIf.swift new file mode 100644 index 00000000..8c9e662a --- /dev/null +++ b/packages/voltra/ios/ui/Views/VoltraControlIf.swift @@ -0,0 +1,28 @@ +import SwiftUI + +public struct VoltraControlIf: VoltraView { + public typealias Parameters = EmptyParameters + + public let element: VoltraElement + + public init(_ element: VoltraElement) { + self.element = element + } + + public var body: some View { + if evaluateCondition() { + element.children ?? .empty + } else { + element.rawComponentProp("else") + } + } + + private func evaluateCondition() -> Bool { + guard let conditionJSON = element.rawPropJSON("condition"), + let condition = try? ResolvableValueParser.parseCondition(conditionJSON) + else { + return false + } + return ResolvableValueEvaluator.evaluate(condition, environment: element.resolvableEnvironment) + } +} diff --git a/packages/voltra/ios/ui/Views/VoltraControlSwitch.swift b/packages/voltra/ios/ui/Views/VoltraControlSwitch.swift new file mode 100644 index 00000000..1e738e54 --- /dev/null +++ b/packages/voltra/ios/ui/Views/VoltraControlSwitch.swift @@ -0,0 +1,53 @@ +import SwiftUI + +public struct VoltraControlSwitch: VoltraView { + public typealias Parameters = EmptyParameters + + public let element: VoltraElement + + public init(_ element: VoltraElement) { + self.element = element + } + + public var body: some View { + resolvedNode + } + + private var resolvedNode: VoltraNode { + guard let casesJSON = element.rawPropJSON("cases"), + case let .object(casesDict) = casesJSON + else { + return .empty + } + + let key: String + if let valueJSON = element.props?["value"] { + key = matchKey(for: valueJSON) + } else { + key = ResolvableWireKey.defaultCase + } + + let caseJSON = casesDict[key] ?? casesDict[ResolvableWireKey.defaultCase] + guard let caseJSON else { return .empty } + + return VoltraNode(from: caseJSON, stylesheet: element.stylesheet, sharedElements: element.sharedElements) + } + + private func matchKey(for value: JSONValue) -> String { + switch value { + case .null: + return "null" + case let .bool(b): + return b ? "true" : "false" + case let .int(i): + return String(i) + case let .double(d): + if d.isFinite, d.rounded(.towardZero) == d { return String(Int(d)) } + return String(d) + case let .string(s): + return s + default: + return ResolvableWireKey.defaultCase + } + } +} diff --git a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx index 2a223c4a..ace16f72 100644 --- a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx +++ b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx @@ -8,13 +8,7 @@ describe('Android Material colors via resolvable env', () => { const output = renderAndroidWidgetToString([ { size: { width: 150, height: 100 }, - content: ( - - ), + content: , }, ]) diff --git a/packages/voltra/src/__tests__/widget-server.node.test.tsx b/packages/voltra/src/__tests__/widget-server.node.test.tsx index e9114e22..481c475f 100644 --- a/packages/voltra/src/__tests__/widget-server.node.test.tsx +++ b/packages/voltra/src/__tests__/widget-server.node.test.tsx @@ -146,13 +146,7 @@ describe('server package split', () => { const variants = [ { size: { width: 150, height: 100 }, - content: ( - - ), + content: , }, ] const handler = createAndroidWidgetUpdateHandler({ diff --git a/packages/voltra/src/android/payload/component-ids.ts b/packages/voltra/src/android/payload/component-ids.ts index 8d47d5e3..7c65f53f 100644 --- a/packages/voltra/src/android/payload/component-ids.ts +++ b/packages/voltra/src/android/payload/component-ids.ts @@ -28,7 +28,9 @@ export const ANDROID_COMPONENT_NAME_TO_ID: Record = { AndroidSquareIconButton: 17, AndroidText: 18, AndroidTitleBar: 19, - AndroidChart: 20, + AndroidControlIf: 20, + AndroidControlSwitch: 21, + AndroidChart: 22, } /** @@ -55,7 +57,9 @@ export const ANDROID_COMPONENT_ID_TO_NAME: Record = { 17: 'AndroidSquareIconButton', 18: 'AndroidText', 19: 'AndroidTitleBar', - 20: 'AndroidChart', + 20: 'AndroidControlIf', + 21: 'AndroidControlSwitch', + 22: 'AndroidChart', } /** diff --git a/packages/voltra/src/jsx/ControlIf.tsx b/packages/voltra/src/jsx/ControlIf.tsx new file mode 100644 index 00000000..b2d2abe2 --- /dev/null +++ b/packages/voltra/src/jsx/ControlIf.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' +import type { ResolvableCondition } from '@use-voltra/core' +import { createVoltraComponent } from './createVoltraComponent.js' + +export type ControlIfProps = { + id?: string + condition: ResolvableCondition + children?: ReactNode + else?: ReactNode +} + +export const ControlIf = createVoltraComponent('ControlIf') diff --git a/packages/voltra/src/jsx/ControlSwitch.tsx b/packages/voltra/src/jsx/ControlSwitch.tsx new file mode 100644 index 00000000..e5a1dffa --- /dev/null +++ b/packages/voltra/src/jsx/ControlSwitch.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' +import type { ResolvableValue } from '@use-voltra/core' +import { createVoltraComponent } from './createVoltraComponent.js' + +export type ControlSwitchProps = { + id?: string + value: ResolvableValue + cases: Record & { default?: ReactNode } +} + +export const ControlSwitch = createVoltraComponent('ControlSwitch') diff --git a/packages/voltra/src/jsx/primitives.ts b/packages/voltra/src/jsx/primitives.ts index 519f15de..d35cd3e4 100644 --- a/packages/voltra/src/jsx/primitives.ts +++ b/packages/voltra/src/jsx/primitives.ts @@ -1,3 +1,5 @@ +export * from './ControlIf.js' +export * from './ControlSwitch.js' export * from './AreaMark.js' export * from './BarMark.js' export * from './Button.js' diff --git a/packages/voltra/src/jsx/props/AndroidControlIf.ts b/packages/voltra/src/jsx/props/AndroidControlIf.ts new file mode 100644 index 00000000..d50c382c --- /dev/null +++ b/packages/voltra/src/jsx/props/AndroidControlIf.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidControlIfProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/AndroidControlSwitch.ts b/packages/voltra/src/jsx/props/AndroidControlSwitch.ts new file mode 100644 index 00000000..a10a23c8 --- /dev/null +++ b/packages/voltra/src/jsx/props/AndroidControlSwitch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidControlSwitchProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/AndroidIf.ts b/packages/voltra/src/jsx/props/AndroidIf.ts new file mode 100644 index 00000000..2ced4504 --- /dev/null +++ b/packages/voltra/src/jsx/props/AndroidIf.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidIfProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/AndroidMatch.ts b/packages/voltra/src/jsx/props/AndroidMatch.ts new file mode 100644 index 00000000..0da148ca --- /dev/null +++ b/packages/voltra/src/jsx/props/AndroidMatch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type AndroidMatchProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/ControlIf.ts b/packages/voltra/src/jsx/props/ControlIf.ts new file mode 100644 index 00000000..2688208a --- /dev/null +++ b/packages/voltra/src/jsx/props/ControlIf.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type ControlIfProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/ControlSwitch.ts b/packages/voltra/src/jsx/props/ControlSwitch.ts new file mode 100644 index 00000000..d5889e9d --- /dev/null +++ b/packages/voltra/src/jsx/props/ControlSwitch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type ControlSwitchProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/If.ts b/packages/voltra/src/jsx/props/If.ts new file mode 100644 index 00000000..6391b018 --- /dev/null +++ b/packages/voltra/src/jsx/props/If.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type IfProps = VoltraBaseProps diff --git a/packages/voltra/src/jsx/props/Switch.ts b/packages/voltra/src/jsx/props/Switch.ts new file mode 100644 index 00000000..62ae0a59 --- /dev/null +++ b/packages/voltra/src/jsx/props/Switch.ts @@ -0,0 +1,7 @@ +// ๐Ÿค– AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type SwitchProps = VoltraBaseProps diff --git a/packages/voltra/src/payload/component-ids.ts b/packages/voltra/src/payload/component-ids.ts index e7e0fdf7..4170f35d 100644 --- a/packages/voltra/src/payload/component-ids.ts +++ b/packages/voltra/src/payload/component-ids.ts @@ -30,6 +30,8 @@ export const COMPONENT_NAME_TO_ID: Record = { Link: 19, View: 20, Chart: 21, + ControlIf: 22, + ControlSwitch: 23, } /** @@ -58,6 +60,8 @@ export const COMPONENT_ID_TO_NAME: Record = { 19: 'Link', 20: 'View', 21: 'Chart', + 22: 'ControlIf', + 23: 'ControlSwitch', } /** diff --git a/packages/voltra/src/renderer/__tests__/control-flow.node.test.ts b/packages/voltra/src/renderer/__tests__/control-flow.node.test.ts new file mode 100644 index 00000000..1ea82da5 --- /dev/null +++ b/packages/voltra/src/renderer/__tests__/control-flow.node.test.ts @@ -0,0 +1,185 @@ +import { createVoltraComponent, createVoltraRenderer, env, eq, inList, match } from '@use-voltra/core' + +const ControlIf = createVoltraComponent>('ControlIf') +const ControlSwitch = createVoltraComponent>('ControlSwitch') +const Text = createVoltraComponent>('Text') +const View = createVoltraComponent>('View') + +const componentRegistry = { + getComponentId: (name: string) => { + const ids: Record = { ControlIf: 22, ControlSwitch: 23, Text: 0, View: 20 } + const id = ids[name] + if (id === undefined) throw new Error(`Unknown component: ${name}`) + return id + }, +} + +describe(' control-flow component', () => { + test('renders then-branch as children with serialized condition', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: ControlIf, + props: { + condition: eq(env.renderingMode, 'accented'), + children: { type: Text, props: { children: 'Accented' } }, + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 22, + c: { t: 0, c: 'Accented' }, + p: { + // condition: eq(env.renderingMode, 'accented') + // eq opcode = 0, env.renderingMode = {$rv:[0,0]}, 'accented' + cond: [0, { $rv: [0, 0] }, 'accented'], + }, + }, + }) + }) + + test('renders else branch in props when provided', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: ControlIf, + props: { + condition: eq(env.renderingMode, 'accented'), + children: { type: Text, props: { children: 'Accented' } }, + else: { type: Text, props: { children: 'Other' } }, + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 22, + c: { t: 0, c: 'Accented' }, + p: { + cond: [0, { $rv: [0, 0] }, 'accented'], + els: { t: 0, c: 'Other' }, + }, + }, + }) + }) + + test('renders with empty children when none provided', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: ControlIf, + props: { + condition: inList(env.renderingMode, ['accented', 'fullColor']), + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 22, + c: [], + p: { + // inList opcode = 5 + cond: [5, { $rv: [0, 0] }, ['accented', 'fullColor']], + }, + }, + }) + }) +}) + +describe(' control-flow component', () => { + test('renders all cases with serialized env value', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: ControlSwitch, + props: { + value: env.renderingMode, + cases: { + accented: { type: Text, props: { children: 'Accented' } }, + fullColor: { type: Text, props: { children: 'Full Color' } }, + default: { type: Text, props: { children: 'Default' } }, + }, + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 23, + p: { + // env.renderingMode = {$rv:[0,0]} + v: { $rv: [0, 0] }, + cas: { + accented: { t: 0, c: 'Accented' }, + fullColor: { t: 0, c: 'Full Color' }, + default: { t: 0, c: 'Default' }, + }, + }, + }, + }) + }) + + test('supports match() expression as value', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: ControlSwitch, + props: { + value: match(env.renderingMode, { + accented: 'dark', + default: 'light', + }), + cases: { + dark: { type: Text, props: { children: 'Dark' } }, + light: { type: Text, props: { children: 'Light' } }, + }, + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 23, + p: { + v: { $rv: [2, { $rv: [0, 0] }, { accented: 'dark', default: 'light' }] }, + cas: { + dark: { t: 0, c: 'Dark' }, + light: { t: 0, c: 'Light' }, + }, + }, + }, + }) + }) + + test('renders cases with component subtrees', () => { + const renderer = createVoltraRenderer(componentRegistry) + + renderer.addRootNode('main', { + type: ControlSwitch, + props: { + value: env.renderingMode, + cases: { + accented: { type: View, props: {} }, + default: { type: View, props: {} }, + }, + }, + } as never) + + expect(renderer.render()).toEqual({ + v: 2, + main: { + t: 23, + p: { + v: { $rv: [0, 0] }, + cas: { + accented: { t: 20 }, + default: { t: 20 }, + }, + }, + }, + }) + }) +}) From bfedb80c65bf8243958448af731fcc0af24b8f97 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 15 Apr 2026 12:10:58 +0200 Subject: [PATCH 6/8] fix: implement ControlIf/ControlSwitch rendering and fix server registries - Add Android Glance renderers (ControlFlowRenderers.kt) for ControlIf and ControlSwitch, dispatched from RenderElement/RenderElementWithModifier - Expose ResolvableValueEvaluator.evaluateCondition() for renderer use; props are pre-resolved by ResolvablePayloadResolver so no env needed at render - Add ControlIf/ControlSwitch to ios-server component registry (IDs 22/23) - Fix android-server registry: AndroidControlIf=20, AndroidControlSwitch=21, AndroidChart=22 (was incorrectly mapped to 20, shadowing the control-flow IDs) Co-Authored-By: Claude Sonnet 4.6 --- example/app.json | 9 +- .../ios/IosResolvablePlaygroundWidget.tsx | 284 ++++++++++++------ packages/android-server/src/index.ts | 4 +- packages/ios-server/src/index.ts | 2 + .../glance/renderers/ControlFlowRenderers.kt | 44 +++ .../voltra/glance/renderers/RenderCommon.kt | 4 + .../resolvable/ResolvableValueEvaluator.kt | 15 + 7 files changed, 267 insertions(+), 95 deletions(-) create mode 100644 packages/voltra/android/src/main/java/voltra/glance/renderers/ControlFlowRenderers.kt diff --git a/example/app.json b/example/app.json index 81bdb64e..5dace50d 100644 --- a/example/app.json +++ b/example/app.json @@ -58,7 +58,14 @@ "id": "resolvable_playground", "displayName": "Resolvable Values", "description": "Minimal widget playground for runtime env resolution", - "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], + "supportedFamilies": [ + "systemSmall", + "systemMedium", + "systemLarge", + "accessoryCircular", + "accessoryRectangular", + "accessoryInline" + ], "initialStatePath": "./widgets/ios/ios-resolvable-playground-initial.tsx" } ], diff --git a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx index c89787db..067bf349 100644 --- a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx +++ b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx @@ -1,7 +1,12 @@ import React from 'react' import { env, eq, when, Voltra, type WidgetVariants } from '@use-voltra/ios' -type WidgetSize = 'small' | 'medium' +type WidgetSize = + | 'small' + | 'medium' + | 'accessoryCircular' + | 'accessoryRectangular' + | 'accessoryInline' /** Per-mode label text color (avoids nesting `match` inside `when` for style types). */ const labelByMode = when( @@ -50,9 +55,189 @@ const Row = ({ label, children }: { label: string; children: React.ReactNode }) ) } +const CompactRow = ({ + label, + labelWidth, + labelFontSize, + gap, + childGap, + children, +}: { + label: string + labelWidth: number + labelFontSize: number + gap: number + childGap: number + children: React.ReactNode +}) => ( + + + {label} + + {children} + +) + +const ModeSwitch = ({ box, fontSize }: { box: number; fontSize: number }) => ( + + A + + ), + fullColor: ( + + F + + ), + default: ( + + V + + ), + }} + /> +) + +const BackgroundToggle = ({ box, fontSize }: { box: number; fontSize: number }) => ( + + N + + } + > + + Y + + +) + const IosResolvablePlaygroundBody = ({ size }: { size: WidgetSize }) => { + if (size === 'accessoryCircular') { + const box = 18 + const fontSize = 9 + return ( + + + RV + + + + + ) + } + + if (size === 'accessoryRectangular') { + const box = 22 + const fontSize = 10 + return ( + + + + Resolvable + + + + + + + + + + ) + } + + if (size === 'accessoryInline') { + const box = 20 + const fontSize = 10 + return ( + + RV + + + + ) + } + const compact = size === 'small' const box = compact ? 26 : 28 + const labelFont = compact ? 13 : 14 return ( { - - - A - - - ), - fullColor: ( - - - F - - - ), - default: ( - - - V - - - ), - }} - /> + - - - N - - - } - > - - - Y - - - + @@ -191,4 +286,7 @@ export const resolvablePlaygroundVariants: WidgetVariants = { systemSmall: , systemMedium: , systemLarge: , + accessoryCircular: , + accessoryRectangular: , + accessoryInline: , } diff --git a/packages/android-server/src/index.ts b/packages/android-server/src/index.ts index 80b90539..9643123f 100644 --- a/packages/android-server/src/index.ts +++ b/packages/android-server/src/index.ts @@ -82,7 +82,9 @@ const ANDROID_COMPONENT_NAME_TO_ID: Record = { AndroidSquareIconButton: 17, AndroidText: 18, AndroidTitleBar: 19, - AndroidChart: 20, + AndroidControlIf: 20, + AndroidControlSwitch: 21, + AndroidChart: 22, } const getAndroidComponentId = (name: string): number => { diff --git a/packages/ios-server/src/index.ts b/packages/ios-server/src/index.ts index 796c1249..e8f02050 100644 --- a/packages/ios-server/src/index.ts +++ b/packages/ios-server/src/index.ts @@ -73,6 +73,8 @@ const COMPONENT_NAME_TO_ID: Record = { Link: 19, View: 20, Chart: 21, + ControlIf: 22, + ControlSwitch: 23, } const defaultComponentRegistry: ComponentRegistry = { diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/ControlFlowRenderers.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/ControlFlowRenderers.kt new file mode 100644 index 00000000..fc51ea85 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/ControlFlowRenderers.kt @@ -0,0 +1,44 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import voltra.glance.LocalVoltraRenderContext +import voltra.models.VoltraElement +import voltra.models.componentProp +import voltra.models.resolveToVoltraNode +import voltra.resolvable.ResolvableValueEvaluator + +@Composable +fun RenderControlIf(element: VoltraElement) { + val context = LocalVoltraRenderContext.current + + if (ResolvableValueEvaluator.evaluateCondition(element.p?.get("condition"))) { + RenderNode(element.c) + } else { + val elseNode = element.componentProp("else", context.sharedStyles, context.sharedElements) + RenderNode(elseNode) + } +} + +@Composable +fun RenderControlSwitch(element: VoltraElement) { + val context = LocalVoltraRenderContext.current + + @Suppress("UNCHECKED_CAST") + val cases = element.p?.get("cases") as? Map ?: return + val key = controlSwitchMatchKey(element.p?.get("value")) + val caseValue = cases[key] ?: cases["default"] ?: return + + RenderNode(resolveToVoltraNode(caseValue, context.sharedStyles, context.sharedElements)) +} + +private fun controlSwitchMatchKey(value: Any?): String = + when (value) { + null -> "null" + is Boolean -> if (value) "true" else "false" + is Number -> { + val d = value.toDouble() + if (d.isFinite() && kotlin.math.floor(d) == d) d.toLong().toString() else d.toString() + } + is String -> value + else -> "default" + } diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt index a6808907..59dff831 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -159,6 +159,8 @@ private fun RenderElement(element: VoltraElement) { ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element) ComponentTypeID.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element) ComponentTypeID.CHART -> RenderChart(element) + ComponentTypeID.CONTROL_IF -> RenderControlIf(element) + ComponentTypeID.CONTROL_SWITCH -> RenderControlSwitch(element) } } @@ -194,5 +196,7 @@ fun RenderElementWithModifier( ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element, modifier) ComponentTypeID.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element, modifier) ComponentTypeID.CHART -> RenderChart(element, modifier) + ComponentTypeID.CONTROL_IF -> RenderControlIf(element) + ComponentTypeID.CONTROL_SWITCH -> RenderControlSwitch(element) } } diff --git a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt index d05ec264..b19c582c 100644 --- a/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt +++ b/packages/voltra/android/src/main/java/voltra/resolvable/ResolvableValueEvaluator.kt @@ -360,6 +360,21 @@ internal object ResolvableValueEvaluator { else -> null } + /** + * Evaluate a pre-resolved condition tuple (e.g., from `element.p["condition"]`). + * By the time this is called, all `$rv` env references inside the tuple are already + * replaced with literal values by [ResolvablePayloadResolver], so the environment + * is not needed for evaluation. + */ + internal fun evaluateCondition(conditionTuple: Any?): Boolean = + try { + val condition = parseCondition(conditionTuple) + evaluate(condition, ResolvableRuntimeEnvironment { null }) + } catch (e: Exception) { + logWarning("Failed to evaluate condition: ${e.message}", e) + false + } + private fun logWarning( message: String, error: Throwable?, From 70ed1a5b379f7282ead2482a463afb243a228f6d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 15 Apr 2026 12:38:00 +0200 Subject: [PATCH 7/8] chore: update example --- .../ios/IosResolvablePlaygroundWidget.tsx | 165 ++++++++---------- 1 file changed, 77 insertions(+), 88 deletions(-) diff --git a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx index 067bf349..35380f00 100644 --- a/example/widgets/ios/IosResolvablePlaygroundWidget.tsx +++ b/example/widgets/ios/IosResolvablePlaygroundWidget.tsx @@ -55,36 +55,6 @@ const Row = ({ label, children }: { label: string; children: React.ReactNode }) ) } -const CompactRow = ({ - label, - labelWidth, - labelFontSize, - gap, - childGap, - children, -}: { - label: string - labelWidth: number - labelFontSize: number - gap: number - childGap: number - children: React.ReactNode -}) => ( - - - {label} - - {children} - -) - const ModeSwitch = ({ box, fontSize }: { box: number; fontSize: number }) => ( ) -const IosResolvablePlaygroundBody = ({ size }: { size: WidgetSize }) => { - if (size === 'accessoryCircular') { - const box = 18 - const fontSize = 9 - return ( - - - RV - - +/** + * Lock screen ยท circular (~76ร—76 pt): ring frame, env label, mode strip, chrome row. + */ +export function ResolvableAccessoryCircular() { + const modeBox = 15 + const chromeBox = 15 + const modeFont = 8 + const chromeFont = 8 + + return ( + + + + ) +} + +/** + * Lock screen ยท rectangular (~172ร—76 pt): title row, divider, render + container rows. + */ +export function ResolvableAccessoryRectangular() { + const modeBox = 20 + const chromeBox = 20 + const modeFont = 16 + + return ( + + + mode + + + + bg + + + + ) +} + +/** + * Lock screen ยท inline (~172ร—40 pt): label block, rule, controls in one band. + */ +export function ResolvableAccessoryInline() { + const modeBox = 14 + const chromeBox = 14 + const modeFont = 9 + + return ( + + + mode + + + + bg + - ) + + ) +} + +const IosResolvablePlaygroundBody = ({ size }: { size: WidgetSize }) => { + if (size === 'accessoryCircular') { + return } if (size === 'accessoryRectangular') { - const box = 22 - const fontSize = 10 - return ( - - - - Resolvable - - - - - - - - - - ) + return } if (size === 'accessoryInline') { - const box = 20 - const fontSize = 10 - return ( - - RV - - - - ) + return } const compact = size === 'small' @@ -283,10 +272,10 @@ export const IosResolvablePlaygroundWidget = ({ size = 'medium' }: { size?: Widg } export const resolvablePlaygroundVariants: WidgetVariants = { - systemSmall: , - systemMedium: , - systemLarge: , - accessoryCircular: , - accessoryRectangular: , - accessoryInline: , + systemSmall: , + systemMedium: , + systemLarge: , + accessoryCircular: , + accessoryRectangular: , + accessoryInline: Ai, } From 7295bbb558fe689730c82c1165e72943c9dd92cc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 29 Apr 2026 13:44:32 +0200 Subject: [PATCH 8/8] fix: keep Android component IDs append-only --- packages/android/src/payload/component-ids.ts | 62 +++++++++---------- .../java/voltra/payload/ComponentTypeID.kt | 12 ++-- packages/voltra/data/components.json | 30 ++++----- .../src/android/payload/component-ids.ts | 12 ++-- 4 files changed, 55 insertions(+), 61 deletions(-) diff --git a/packages/android/src/payload/component-ids.ts b/packages/android/src/payload/component-ids.ts index 7c65f53f..e824ff2d 100644 --- a/packages/android/src/payload/component-ids.ts +++ b/packages/android/src/payload/component-ids.ts @@ -8,29 +8,29 @@ * Component IDs are assigned sequentially based on order in components.json (0-indexed) */ export const ANDROID_COMPONENT_NAME_TO_ID: Record = { - AndroidFilledButton: 0, - AndroidImage: 1, - AndroidSwitch: 2, - AndroidCheckBox: 3, - AndroidRadioButton: 4, - AndroidBox: 5, - AndroidButton: 6, - AndroidCircleIconButton: 7, - AndroidCircularProgressIndicator: 8, - AndroidColumn: 9, - AndroidLazyColumn: 10, - AndroidLazyVerticalGrid: 11, - AndroidLinearProgressIndicator: 12, - AndroidOutlineButton: 13, - AndroidRow: 14, - AndroidScaffold: 15, - AndroidSpacer: 16, - AndroidSquareIconButton: 17, - AndroidText: 18, - AndroidTitleBar: 19, - AndroidControlIf: 20, - AndroidControlSwitch: 21, - AndroidChart: 22, + 'AndroidFilledButton': 0, + 'AndroidImage': 1, + 'AndroidSwitch': 2, + 'AndroidCheckBox': 3, + 'AndroidRadioButton': 4, + 'AndroidBox': 5, + 'AndroidButton': 6, + 'AndroidCircleIconButton': 7, + 'AndroidCircularProgressIndicator': 8, + 'AndroidColumn': 9, + 'AndroidLazyColumn': 10, + 'AndroidLazyVerticalGrid': 11, + 'AndroidLinearProgressIndicator': 12, + 'AndroidOutlineButton': 13, + 'AndroidRow': 14, + 'AndroidScaffold': 15, + 'AndroidSpacer': 16, + 'AndroidSquareIconButton': 17, + 'AndroidText': 18, + 'AndroidTitleBar': 19, + 'AndroidChart': 20, + 'AndroidControlIf': 21, + 'AndroidControlSwitch': 22 } /** @@ -57,9 +57,9 @@ export const ANDROID_COMPONENT_ID_TO_NAME: Record = { 17: 'AndroidSquareIconButton', 18: 'AndroidText', 19: 'AndroidTitleBar', - 20: 'AndroidControlIf', - 21: 'AndroidControlSwitch', - 22: 'AndroidChart', + 20: 'AndroidChart', + 21: 'AndroidControlIf', + 22: 'AndroidControlSwitch' } /** @@ -69,11 +69,7 @@ export const ANDROID_COMPONENT_ID_TO_NAME: Record = { export function getAndroidComponentId(name: string): number { const id = ANDROID_COMPONENT_NAME_TO_ID[name] if (id === undefined) { - throw new Error( - `Unknown Android component name: "${name}". Available components: ${Object.keys( - ANDROID_COMPONENT_NAME_TO_ID - ).join(', ')}` - ) + throw new Error(`Unknown Android component name: "${name}". Available components: ${Object.keys(ANDROID_COMPONENT_NAME_TO_ID).join(', ')}`) } return id } @@ -85,9 +81,7 @@ export function getAndroidComponentId(name: string): number { export function getAndroidComponentName(id: number): string { const name = ANDROID_COMPONENT_ID_TO_NAME[id] if (name === undefined) { - throw new Error( - `Unknown Android component ID: ${id}. Valid IDs: 0-${Object.keys(ANDROID_COMPONENT_ID_TO_NAME).length - 1}` - ) + throw new Error(`Unknown Android component ID: ${id}. Valid IDs: 0-${Object.keys(ANDROID_COMPONENT_ID_TO_NAME).length - 1}`) } return name } diff --git a/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt b/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt index dd6271a6..25bdebec 100644 --- a/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt +++ b/packages/voltra/android/src/main/java/voltra/payload/ComponentTypeID.kt @@ -32,9 +32,9 @@ object ComponentTypeID { const val SQUARE_ICON_BUTTON = 17 const val TEXT = 18 const val TITLE_BAR = 19 - const val CONTROL_IF = 20 - const val CONTROL_SWITCH = 21 - const val CHART = 22 + const val CHART = 20 + const val CONTROL_IF = 21 + const val CONTROL_SWITCH = 22 /** * Get component name from numeric ID @@ -61,9 +61,9 @@ object ComponentTypeID { 17 -> "AndroidSquareIconButton" 18 -> "AndroidText" 19 -> "AndroidTitleBar" - 20 -> "AndroidControlIf" - 21 -> "AndroidControlSwitch" - 22 -> "AndroidChart" + 20 -> "AndroidChart" + 21 -> "AndroidControlIf" + 22 -> "AndroidControlSwitch" else -> null } } diff --git a/packages/voltra/data/components.json b/packages/voltra/data/components.json index 81d47c9c..6e10035f 100644 --- a/packages/voltra/data/components.json +++ b/packages/voltra/data/components.json @@ -1313,21 +1313,6 @@ "swiftAvailability": "iOS 13.0, macOS 10.15", "parameters": {} }, - { - "name": "AndroidControlIf", - "description": "Control-flow component: renders children when condition is true, else branch otherwise", - "swiftAvailability": "Not available", - "androidAvailability": "Android 12+", - "hasChildren": true, - "parameters": {} - }, - { - "name": "AndroidControlSwitch", - "description": "Control-flow component: selects a child branch by matching a value against cases", - "swiftAvailability": "Not available", - "androidAvailability": "Android 12+", - "parameters": {} - }, { "name": "AndroidChart", "description": "Android charts component for data visualization", @@ -1372,6 +1357,21 @@ "description": "Enable scrolling on the given axis" } } + }, + { + "name": "AndroidControlIf", + "description": "Control-flow component: renders children when condition is true, else branch otherwise", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "hasChildren": true, + "parameters": {} + }, + { + "name": "AndroidControlSwitch", + "description": "Control-flow component: selects a child branch by matching a value against cases", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": {} } ] } diff --git a/packages/voltra/src/android/payload/component-ids.ts b/packages/voltra/src/android/payload/component-ids.ts index 7c65f53f..c841864a 100644 --- a/packages/voltra/src/android/payload/component-ids.ts +++ b/packages/voltra/src/android/payload/component-ids.ts @@ -28,9 +28,9 @@ export const ANDROID_COMPONENT_NAME_TO_ID: Record = { AndroidSquareIconButton: 17, AndroidText: 18, AndroidTitleBar: 19, - AndroidControlIf: 20, - AndroidControlSwitch: 21, - AndroidChart: 22, + AndroidChart: 20, + AndroidControlIf: 21, + AndroidControlSwitch: 22, } /** @@ -57,9 +57,9 @@ export const ANDROID_COMPONENT_ID_TO_NAME: Record = { 17: 'AndroidSquareIconButton', 18: 'AndroidText', 19: 'AndroidTitleBar', - 20: 'AndroidControlIf', - 21: 'AndroidControlSwitch', - 22: 'AndroidChart', + 20: 'AndroidChart', + 21: 'AndroidControlIf', + 22: 'AndroidControlSwitch', } /**