diff --git a/example/app.json b/example/app.json
index 752d545d..c0eea206 100644
--- a/example/app.json
+++ b/example/app.json
@@ -66,6 +66,18 @@
"intervalMinutes": 15,
"refresh": true
}
+ },
+ {
+ "id": "reactive_codegen",
+ "displayName": "Codegen Temperature",
+ "description": "Track 3 PoC: build-time codegen temperature widget",
+ "supportedFamilies": ["systemSmall", "systemMedium"],
+ "initialStatePath": "./widgets/ios/ios-codegen-temperature-initial.tsx",
+ "appIntent": {
+ "parameters": [
+ { "name": "temperature", "title": "Temperature", "default": "22°C" }
+ ]
+ }
}
],
"fonts": [
diff --git a/example/package.json b/example/package.json
index 4f5999c6..8cf7d852 100644
--- a/example/package.json
+++ b/example/package.json
@@ -5,8 +5,8 @@
"scripts": {
"clean": "rm -rf .expo ios",
"start": "expo start --dev-client --clear",
- "prebuild": "expo prebuild",
- "prebuild:clean": "expo prebuild --clean",
+ "prebuild": "npm run build -w @use-voltra/expo-plugin && expo prebuild",
+ "prebuild:clean": "npm run build -w @use-voltra/expo-plugin && expo prebuild --clean",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
diff --git a/example/widgets/ios/IosCodegenTemperatureWidget.tsx b/example/widgets/ios/IosCodegenTemperatureWidget.tsx
new file mode 100644
index 00000000..31e8a757
--- /dev/null
+++ b/example/widgets/ios/IosCodegenTemperatureWidget.tsx
@@ -0,0 +1,12 @@
+import { Voltra, appIntentParam } from 'voltra'
+
+export const IosCodegenTemperatureWidget = () => (
+
+
+ {appIntentParam('temperature')}
+
+
+ Temperature
+
+
+)
\ No newline at end of file
diff --git a/example/widgets/ios/ios-codegen-temperature-initial.tsx b/example/widgets/ios/ios-codegen-temperature-initial.tsx
new file mode 100644
index 00000000..28512238
--- /dev/null
+++ b/example/widgets/ios/ios-codegen-temperature-initial.tsx
@@ -0,0 +1,10 @@
+import type { WidgetVariants } from 'voltra'
+
+import { IosCodegenTemperatureWidget } from './IosCodegenTemperatureWidget'
+
+const initialState: WidgetVariants = {
+ systemSmall: ,
+ systemMedium: ,
+}
+
+export default initialState
\ No newline at end of file
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift-codegen.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift-codegen.ts
new file mode 100644
index 00000000..549d35c7
--- /dev/null
+++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift-codegen.ts
@@ -0,0 +1,370 @@
+import type { AppIntentParameter, IOSWidgetConfig as WidgetConfig, IOSWidgetFamily as WidgetFamily } from '../../types'
+import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants'
+
+// Component type IDs matching packages/ios/src/payload/component-ids.ts
+const T_TEXT = 0
+const T_VSTACK = 11
+const T_HSTACK = 12
+const T_ZSTACK = 13
+
+interface VoltraNode {
+ t: number
+ c?: VoltraNode[] | string
+ p?: Record
+}
+
+interface VoltraPayload {
+ v: number
+ s?: Record[]
+ [family: string]: any
+}
+
+/**
+ * Sanitizes a widget id to a valid Swift identifier (hyphens and other non-alphanumeric chars → underscores).
+ */
+export function sanitizeSwiftId(id: string): string {
+ return id.replace(/[^a-zA-Z0-9_]/g, '_')
+}
+
+/**
+ * Generates a self-contained Swift file for a codegen widget.
+ * Emits Intent, Entry, Provider, View, and Widget structs — no VoltraWidget SDK import.
+ */
+export function generateCodegenWidgetCode(widget: WidgetConfig, prerenderedPayloadJson: string): string {
+ const payload = JSON.parse(prerenderedPayloadJson) as VoltraPayload
+ const stylesheet: Record[] = payload.s ?? []
+ const params: AppIntentParameter[] = widget.appIntent?.parameters ?? []
+ const paramNames = params.map((p) => p.name)
+ const safeId = sanitizeSwiftId(widget.id)
+
+ const allFamilies = (widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES) as WidgetFamily[]
+ const families = allFamilies.filter((f) => payload[f] != null)
+
+ const displayName = labelToString(widget.displayName)
+ const description = labelToString(widget.description)
+
+ // kp() emits a Swift key-path backslash prefix — workaround for dedent using .raw templates
+ const kp = (key: string) => `\\.${key}`
+
+ // --- Intent ---
+ const intentParamsCode = params
+ .map(
+ (p) =>
+ ` @Parameter(title: "${esc(p.title)}", default: "${esc(p.default)}")\n var ${p.name}: String?`
+ )
+ .join('\n\n')
+
+ // --- Entry ---
+ const entryFieldsCode = params.map((p) => ` let ${p.name}: String`).join('\n')
+
+ // --- Provider ---
+ const placeholderArgs = params.map((p) => `${p.name}: "${esc(p.default)}"`).join(', ')
+ const intentArgs = params.map((p) => `${p.name}: intent.${p.name} ?? "${esc(p.default)}"`).join(', ')
+
+ // --- View: per-family view properties ---
+ const familyViewProps = families
+ .map((f) => {
+ const node = payload[f] as VoltraNode
+ const body = translateNode(node, stylesheet, paramNames, 4)
+ return [` private var ${f}View: some View {`, body, ` }`].join('\n')
+ })
+ .join('\n\n')
+
+ // --- View: switch cases ---
+ const switchCasesCode = families
+ .map((f, i) => {
+ if (i === families.length - 1) return ` default:\n ${f}View`
+ return ` case ${WIDGET_FAMILY_MAP[f]}:\n ${f}View`
+ })
+ .join('\n')
+
+ // --- Widget ---
+ const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ')
+
+ return [
+ `//`,
+ `// VoltraCodegen_${safeId}.swift`,
+ `//`,
+ `// Auto-generated by Voltra config plugin (Track 3 — build-time codegen).`,
+ `// Do not edit — regenerated on every expo prebuild.`,
+ `//`,
+ ``,
+ `import AppIntents`,
+ `import SwiftUI`,
+ `import WidgetKit`,
+ ``,
+ `// MARK: - Intent`,
+ ``,
+ `struct VoltraCodegenIntent_${safeId}: WidgetConfigurationIntent {`,
+ ` static var title: LocalizedStringResource = "${esc(displayName)}"`,
+ ``,
+ intentParamsCode,
+ `}`,
+ ``,
+ `// MARK: - Entry`,
+ ``,
+ `struct VoltraCodegenEntry_${safeId}: TimelineEntry {`,
+ ` let date: Date`,
+ entryFieldsCode,
+ `}`,
+ ``,
+ `// MARK: - Provider`,
+ ``,
+ `struct VoltraCodegenProvider_${safeId}: AppIntentTimelineProvider {`,
+ ` typealias Intent = VoltraCodegenIntent_${safeId}`,
+ ` typealias Entry = VoltraCodegenEntry_${safeId}`,
+ ``,
+ ` func placeholder(in context: Context) -> Entry {`,
+ ` Entry(date: Date(), ${placeholderArgs})`,
+ ` }`,
+ ``,
+ ` func snapshot(for intent: Intent, in context: Context) async -> Entry {`,
+ ` Entry(date: Date(), ${intentArgs})`,
+ ` }`,
+ ``,
+ ` func timeline(for intent: Intent, in context: Context) async -> Timeline {`,
+ ` let entry = Entry(date: Date(), ${intentArgs})`,
+ ` return Timeline(entries: [entry], policy: .never)`,
+ ` }`,
+ `}`,
+ ``,
+ `// MARK: - View`,
+ ``,
+ `struct VoltraCodegenView_${safeId}: View {`,
+ ` let entry: VoltraCodegenEntry_${safeId}`,
+ ` @Environment(${kp('widgetFamily')}) private var widgetFamily`,
+ ``,
+ ` var body: some View {`,
+ ` switch widgetFamily {`,
+ switchCasesCode,
+ ` }`,
+ ` }`,
+ ``,
+ familyViewProps,
+ `}`,
+ ``,
+ `// MARK: - Widget`,
+ ``,
+ `struct VoltraCodegenWidget_${safeId}: Widget {`,
+ ` var body: some WidgetConfiguration {`,
+ ` AppIntentConfiguration(`,
+ ` kind: "Voltra_Widget_${widget.id}",`,
+ ` intent: VoltraCodegenIntent_${safeId}.self,`,
+ ` provider: VoltraCodegenProvider_${safeId}()`,
+ ` ) { entry in`,
+ ` VoltraCodegenView_${safeId}(entry: entry)`,
+ ` .containerBackground(.fill.tertiary, for: .widget)`,
+ ` }`,
+ ` .configurationDisplayName("${esc(displayName)}")`,
+ ` .description("${esc(description)}")`,
+ ` .supportedFamilies([${familiesSwift}])`,
+ ` .contentMarginsDisabled()`,
+ ` }`,
+ `}`,
+ ].join('\n')
+}
+
+// ============================================================================
+// JSON tree → SwiftUI translator
+// ============================================================================
+
+function translateNode(
+ node: VoltraNode,
+ stylesheet: Record[],
+ paramNames: string[],
+ indent: number
+): string {
+ const pad = ' '.repeat(indent)
+ const props = resolveNodeProps(node, stylesheet)
+
+ switch (node.t) {
+ case T_TEXT: {
+ const content = typeof node.c === 'string' ? node.c : ''
+ const textExpr = resolveTextContent(content, paramNames)
+ const mods = textModifiers(props)
+ if (mods.length === 0) return `${pad}Text(${textExpr})`
+ return [`${pad}Text(${textExpr})`, ...mods.map((m) => `${pad} ${m}`)].join('\n')
+ }
+
+ case T_VSTACK: {
+ const children = (node.c as VoltraNode[]).map((child) =>
+ translateNode(child, stylesheet, paramNames, indent + 2)
+ )
+ const args = stackArgs(props, 'V')
+ const mods = containerModifiers(props)
+ return [
+ `${pad}VStack${args} {`,
+ ...children,
+ `${pad}}`,
+ ...mods.map((m) => `${pad}${m}`),
+ ].join('\n')
+ }
+
+ case T_HSTACK: {
+ const children = (node.c as VoltraNode[]).map((child) =>
+ translateNode(child, stylesheet, paramNames, indent + 2)
+ )
+ const args = stackArgs(props, 'H')
+ const mods = containerModifiers(props)
+ return [
+ `${pad}HStack${args} {`,
+ ...children,
+ `${pad}}`,
+ ...mods.map((m) => `${pad}${m}`),
+ ].join('\n')
+ }
+
+ case T_ZSTACK: {
+ const children = (node.c as VoltraNode[]).map((child) =>
+ translateNode(child, stylesheet, paramNames, indent + 2)
+ )
+ const mods = containerModifiers(props)
+ return [
+ `${pad}ZStack {`,
+ ...children,
+ `${pad}}`,
+ ...mods.map((m) => `${pad}${m}`),
+ ].join('\n')
+ }
+
+ default:
+ return `${pad}EmptyView() // unsupported node type ${node.t}`
+ }
+}
+
+function resolveNodeProps(node: VoltraNode, stylesheet: Record[]): Record {
+ const p = node.p ?? {}
+ const { s: styleIdx, ...directProps } = p
+ const base: Record = typeof styleIdx === 'number' ? { ...(stylesheet[styleIdx] ?? {}) } : {}
+ return { ...base, ...directProps }
+}
+
+function resolveTextContent(content: string, paramNames: string[]): string {
+ const m = content.match(/^\{\{\s*appIntent\.(\w+)\s*\}\}$/)
+ if (m && paramNames.includes(m[1]!)) return `entry.${m[1]}`
+ return `"${esc(content)}"`
+}
+
+function textModifiers(props: Record): string[] {
+ const mods: string[] = []
+ if (props.fs != null) mods.push(`.font(.system(size: ${props.fs}))`)
+ if (props.fw != null) mods.push(`.fontWeight(${fontWeightSwift(String(props.fw))})`)
+ if (props.c != null) mods.push(`.foregroundStyle(${colorExpr(props.c)})`)
+ if (props.mt != null) mods.push(`.padding(.top, ${props.mt})`)
+ if (props.mb != null) mods.push(`.padding(.bottom, ${props.mb})`)
+ if (props.ml != null) mods.push(`.padding(.leading, ${props.ml})`)
+ if (props.mr != null) mods.push(`.padding(.trailing, ${props.mr})`)
+ return mods
+}
+
+function containerModifiers(props: Record): string[] {
+ const mods: string[] = []
+ if (props.pad != null) mods.push(`.padding(${props.pad})`)
+ if (props.fl != null) {
+ const align = aiToFrameAlignment(props.ai)
+ mods.push(`.frame(maxWidth: .infinity, maxHeight: .infinity${align})`)
+ }
+ if (props.bg != null) mods.push(`.background(${colorExpr(props.bg)})`)
+ return mods
+}
+
+function stackArgs(props: Record, axis: 'V' | 'H'): string {
+ const parts: string[] = []
+ const alignment = stackAlignment(props, axis)
+ if (alignment) parts.push(`alignment: ${alignment}`)
+ if (props.sp != null) parts.push(`spacing: ${props.sp}`)
+ return parts.length > 0 ? `(${parts.join(', ')})` : ''
+}
+
+function stackAlignment(props: Record, axis: 'V' | 'H'): string | null {
+ const al = props.al as string | undefined
+ const ai = props.ai as string | undefined
+
+ if (axis === 'V') {
+ if (al === 'leading' || ai === 'flex-start') return '.leading'
+ if (al === 'trailing' || ai === 'flex-end') return '.trailing'
+ if (al === 'center' || ai === 'center') return '.center'
+ } else {
+ if (al === 'top') return '.top'
+ if (al === 'bottom') return '.bottom'
+ if (al === 'center') return '.center'
+ if (al === 'firstTextBaseline') return '.firstTextBaseline'
+ if (al === 'lastTextBaseline') return '.lastTextBaseline'
+ }
+ return null
+}
+
+function aiToFrameAlignment(ai: string | undefined): string {
+ if (ai === 'flex-start') return ', alignment: .topLeading'
+ if (ai === 'flex-end') return ', alignment: .bottomTrailing'
+ if (ai === 'center') return ', alignment: .center'
+ return ''
+}
+
+function fontWeightSwift(fw: string): string {
+ const map: Record = {
+ '100': '.ultraLight',
+ '200': '.thin',
+ '300': '.light',
+ '400': '.regular',
+ '500': '.medium',
+ '600': '.semibold',
+ '700': '.bold',
+ '800': '.heavy',
+ '900': '.black',
+ bold: '.bold',
+ semibold: '.semibold',
+ medium: '.medium',
+ light: '.light',
+ heavy: '.heavy',
+ regular: '.regular',
+ }
+ return map[fw] ?? '.regular'
+}
+
+// ============================================================================
+// Color helpers
+// ============================================================================
+
+function colorExpr(value: string): string {
+ return hexColor(value)
+}
+
+function hexColor(hex: string): string {
+ const rgb = hexToRgb(hex)
+ return rgb ? `Color(red: ${f(rgb.r)}, green: ${f(rgb.g)}, blue: ${f(rgb.b)})` : '.primary'
+}
+
+function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
+ const clean = hex.replace('#', '')
+ if (clean.length === 3) {
+ return {
+ r: parseInt(clean[0]! + clean[0]!, 16) / 255,
+ g: parseInt(clean[1]! + clean[1]!, 16) / 255,
+ b: parseInt(clean[2]! + clean[2]!, 16) / 255,
+ }
+ }
+ if (clean.length === 6) {
+ return {
+ r: parseInt(clean.slice(0, 2), 16) / 255,
+ g: parseInt(clean.slice(2, 4), 16) / 255,
+ b: parseInt(clean.slice(4, 6), 16) / 255,
+ }
+ }
+ return null
+}
+
+const f = (n: number) => n.toFixed(3)
+
+// ============================================================================
+// String helpers
+// ============================================================================
+
+function esc(s: string): string {
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')
+}
+
+function labelToString(label: string | Record): string {
+ if (typeof label === 'string') return label
+ return label['en'] ?? Object.values(label)[0] ?? ''
+}
\ No newline at end of file
diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
index 2fef4df8..518309d3 100644
--- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
+++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts
@@ -14,6 +14,7 @@ import {
import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants'
import type { IOSWidgetConfig } from '../../types'
import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery'
+import { generateCodegenWidgetCode, sanitizeSwiftId } from './swift-codegen'
export interface GenerateSwiftFilesOptions {
targetPath: string
@@ -57,6 +58,22 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr
`Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} widget(s) (localized initial states where configured)`
)
+ // Generate self-contained Swift files for codegen (appIntent) widgets
+ for (const widget of widgets ?? []) {
+ if (!widget.appIntent) continue
+ const perLocale = prerenderedStates.get(widget.id)
+ const payloadJson = perLocale?.get('__default') ?? (perLocale ? [...perLocale.values()][0] : undefined)
+ if (!payloadJson) {
+ logger.warn(`Skipping codegen for widget "${widget.id}": no prerendered payload found (set initialStatePath)`)
+ continue
+ }
+ const codegenContent = generateCodegenWidgetCode(widget, payloadJson)
+ const safeId = sanitizeSwiftId(widget.id)
+ const codegenPath = path.join(targetPath, `VoltraCodegen_${safeId}.swift`)
+ fs.writeFileSync(codegenPath, codegenContent)
+ logger.info(`Generated VoltraCodegen_${safeId}.swift for codegen widget "${widget.id}"`)
+ }
+
// Generate the widget bundle Swift file
const widgetBundleContent =
widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift()
@@ -304,15 +321,31 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string {
* Generates the VoltraWidgetBundle.swift file content with configured widgets
*/
function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string {
- // Generate widget structs
- const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n')
+ const staticWidgets = widgets.filter((w) => !w.appIntent)
+ const codegenWidgets = widgets.filter((w) => w.appIntent)
- // Generate widget bundle body entries
- const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')
+ // Inline struct definitions only for non-codegen widgets
+ const widgetStructs = staticWidgets.map(generateWidgetStruct).join('\n\n')
+
+ // Bundle body: static widgets use VoltraWidget_(), codegen widgets use VoltraCodegenWidget_()
+ const widgetInstances = widgets
+ .map((w) =>
+ w.appIntent ? `VoltraCodegenWidget_${sanitizeSwiftId(w.id)}()` : `VoltraWidget_${w.id}()`
+ )
+ .join('\n ')
const needsFoundation = widgets.some(widgetUsesGalleryLocalization)
const foundationImport = needsFoundation ? 'import Foundation\n' : ''
+ const structSection =
+ staticWidgets.length > 0
+ ? dedent`
+ // MARK: - Home Screen Widget Definitions
+
+ ${widgetStructs}
+ `
+ : ''
+
return dedent`
//
// VoltraWidgetBundle.swift
@@ -336,9 +369,7 @@ function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string {
}
}
- // MARK: - Home Screen Widget Definitions
-
- ${widgetStructs}
+ ${structSection}
`
}
diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts
index 26f85ce3..360f6407 100644
--- a/packages/ios-client/expo-plugin/src/types.ts
+++ b/packages/ios-client/expo-plugin/src/types.ts
@@ -14,6 +14,22 @@ export type IOSWidgetFamily =
| 'accessoryRectangular'
| 'accessoryInline'
+/**
+ * A single user-configurable parameter exposed via AppIntent.
+ */
+export interface AppIntentParameter {
+ name: string
+ title: string
+ default?: string
+}
+
+/**
+ * AppIntent configuration for a reactive widget (iOS 17+).
+ */
+export interface IOSWidgetAppIntentConfig {
+ parameters: AppIntentParameter[]
+}
+
/**
* Configuration for a single iOS home screen widget.
*/
@@ -28,6 +44,7 @@ export interface IOSWidgetConfig {
supportedFamilies?: IOSWidgetFamily[]
initialStatePath?: WidgetInitialStatePath
serverUpdate?: IOSWidgetServerUpdateConfig
+ appIntent?: IOSWidgetAppIntentConfig
}
/**
diff --git a/packages/ios/src/app-intent.ts b/packages/ios/src/app-intent.ts
new file mode 100644
index 00000000..f041a554
--- /dev/null
+++ b/packages/ios/src/app-intent.ts
@@ -0,0 +1,3 @@
+export function appIntentParam(name: string): string {
+ return `{{ appIntent.${name} }}`
+}
\ No newline at end of file
diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts
index 98b89a67..f5d369aa 100644
--- a/packages/ios/src/index.ts
+++ b/packages/ios/src/index.ts
@@ -5,6 +5,7 @@ export {
COMPONENT_ID_TO_NAME,
COMPONENT_NAME_TO_ID,
} from './payload/component-ids.js'
+export { appIntentParam } from './app-intent.js'
export { renderLiveActivityToJson, renderLiveActivityToString } from './live-activity/renderer.js'
export type {
DismissalPolicy,
diff --git a/packages/ios/src/server.ts b/packages/ios/src/server.ts
index d84cdf6a..2651cc8b 100644
--- a/packages/ios/src/server.ts
+++ b/packages/ios/src/server.ts
@@ -8,6 +8,7 @@ import type { LiveActivityVariants } from './live-activity/types.js'
import { ensurePayloadWithinBudget } from './payload.js'
export * as Voltra from './jsx/primitives.js'
+export { appIntentParam } from './app-intent.js'
export { renderWidgetToString } from './widgets/renderer.js'
export type { WidgetVariants } from './widgets/types.js'