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'