Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@
"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": [
Expand Down
4 changes: 2 additions & 2 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions example/widgets/ios/IosCodegenTemperatureWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Voltra, appIntentParam } from '@use-voltra/ios'

export const IosCodegenTemperatureWidget = () => (
<Voltra.VStack style={{ flex: 1, padding: 16, alignItems: 'flex-start' }}>
<Voltra.Text style={{ fontSize: 22, fontWeight: '700', color: 'primary' }}>
{appIntentParam('temperature')}
</Voltra.Text>
<Voltra.Text style={{ fontSize: 14, color: 'primary', marginTop: 6 }}>Temperature</Voltra.Text>
</Voltra.VStack>
)
10 changes: 10 additions & 0 deletions example/widgets/ios/ios-codegen-temperature-initial.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { WidgetVariants } from '@use-voltra/ios'

import { IosCodegenTemperatureWidget } from './IosCodegenTemperatureWidget'

const initialState: WidgetVariants = {
systemSmall: <IosCodegenTemperatureWidget />,
systemMedium: <IosCodegenTemperatureWidget />,
}

export default initialState
346 changes: 346 additions & 0 deletions packages/ios-client/expo-plugin/src/ios-widget/files/swift-codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
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<string, any>
}

interface VoltraPayload {
v: number
s?: Record<string, any>[]
[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<string, any>[] = 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<Entry> {`,
` 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<string, any>[],
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<string, any>[]): Record<string, any> {
const p = node.p ?? {}
const { s: styleIdx, ...directProps } = p
const base: Record<string, any> = 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, any>): 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, any>): 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<string, any>, 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<string, any>, 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<string, string> = {
'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, string>): string {
if (typeof label === 'string') return label
return label['en'] ?? Object.values(label)[0] ?? ''
}
Loading
Loading