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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ android.iml
android/app/libs
android/keystores/debug.keystore
build/
bundle/

## Node.js
node_modules/
Expand Down
20 changes: 20 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@
"intervalMinutes": 15,
"refresh": true
}
},
{
"id": "reactive",
"displayName": {
"en": "Reactive Weather Widget"
},
"description": {
"en": "Track 2 PoC: city name and colors resolved on-device via AppIntent + JS renderer"
},
"supportedFamilies": ["systemSmall", "systemMedium"],
"initialStatePath": "./widgets/ios/ios-reactive-weather-initial.tsx",
"appIntent": {
"parameters": [
{
"name": "city",
"title": "City",
"default": "New York"
}
]
}
}
],
"fonts": [
Expand Down
12 changes: 12 additions & 0 deletions example/server/widget-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { renderWidgetToString } from '@use-voltra/ios-server'
import { createWidgetUpdateNodeHandler } from '@use-voltra/server'
import React from 'react'
import { IosPortfolioWidget } from '../widgets/ios/IosPortfolioWidget'
import { IosReactiveWeatherWidget } from '../widgets/ios/IosReactiveWeatherWidget'
import { AndroidMaterialColorsServerWidget } from '../widgets/android/AndroidMaterialColorsWidget'
import { AndroidPortfolioWidget } from '../widgets/android/AndroidPortfolioWidget'

Expand Down Expand Up @@ -50,6 +51,15 @@ function generatePortfolioData() {

const handler = createWidgetUpdateNodeHandler({
renderIos: async (req: any) => {
if (req.widgetId === 'reactive') {
// Track 2 PoC: server renders the widget with variant-aware values preserved.
// appIntentParam('city') → "{{ appIntent.city }}" in the payload.
// light-dark() colors pass through unchanged.
// The extension resolves both against live device state + AppIntent params.
const content = <IosReactiveWeatherWidget />
return { systemSmall: content, systemMedium: content }
}

if (req.widgetId !== 'portfolio') {
return null
}
Expand Down Expand Up @@ -117,6 +127,8 @@ createServer(handler).listen(PORT, () => {
console.log(`\n Portfolio chart:`)
console.log(` iOS: GET http://localhost:${PORT}?widgetId=portfolio&platform=ios&family=systemSmall`)
console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=portfolio&platform=android`)
console.log(`\n Reactive weather (Track 2 PoC — variant-aware payload):`)
console.log(` iOS: GET http://localhost:${PORT}?widgetId=reactive&platform=ios&family=systemSmall`)
console.log(`\n Material colors:`)
console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=material_colors&platform=android`)
console.log(`\n (Android emulator uses 10.0.2.2 to reach the host machine)`)
Expand Down
41 changes: 41 additions & 0 deletions example/widgets/ios/IosReactiveWeatherWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Voltra, appIntentParam } from 'voltra'

// Track 2 PoC — demonstrates AppIntent reactivity:
// - appIntentParam('city'): user-configurable city name via iOS widget settings
//
// The server renders this JSX to a compact JSON payload that includes the raw
// template expressions. At render time inside the widget extension, VoltraJSRenderer
// resolves them against the current AppIntent parameters — no server push required
// when the user reconfigures the widget.

export const IosReactiveWeatherWidget = () => (
<Voltra.VStack style={{ flex: 1, padding: 16, alignItems: 'flex-start' }}>
<Voltra.Text
style={{
fontSize: 22,
fontWeight: '700',
color: 'primary',
}}
>
{appIntentParam('city')}
</Voltra.Text>
<Voltra.Text
style={{
fontSize: 14,
color: 'primary',
marginTop: 6,
}}
>
Reactive Weather
</Voltra.Text>
<Voltra.Text
style={{
fontSize: 11,
color: 'primary',
marginTop: 8,
}}
>
Edit widget to set your city
</Voltra.Text>
</Voltra.VStack>
)
8 changes: 8 additions & 0 deletions example/widgets/ios/ios-reactive-weather-initial.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react'

import { IosReactiveWeatherWidget } from './IosReactiveWeatherWidget'

export default {
systemSmall: <IosReactiveWeatherWidget />,
systemMedium: <IosReactiveWeatherWidget />,
}
30 changes: 30 additions & 0 deletions packages/ios-client/expo-plugin/src/ios-widget/files/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from 'fs'
import * as path from 'path'

import type { IOSWidgetConfig } from '../../types'
import { logger } from '../../utils/logger'
import { generateAssets } from './assets'
import { generateEntitlements } from './entitlements'
import { generateInfoPlist } from './infoPlist'
Expand All @@ -29,6 +30,30 @@ export interface GenerateWidgetExtensionFilesProps {
*
* This should run before configureXcodeProject so the files exist when Xcode project is configured.
*/
function copyRendererBundle(targetPath: string, projectRoot: string): void {
const dest = path.join(targetPath, 'ios-renderer.js')
const candidates = [
// Standard npm install in the project
path.join(projectRoot, 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'),
// Monorepo: workspace root node_modules (one level above the app)
path.join(projectRoot, '..', 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'),
// Monorepo: packages/ built locally (bundle/ output)
path.join(projectRoot, '..', 'packages', 'ios-renderer', 'bundle', 'ios-renderer.js'),
]

for (const src of candidates) {
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest)
logger.info('Copied ios-renderer.js to widget extension target')
return
}
}

logger.warn(
'ios-renderer.js not found — run `npm run build:bundle -w @use-voltra/ios-renderer` then re-run prebuild'
)
}

export const generateWidgetExtensionFiles: ConfigPlugin<GenerateWidgetExtensionFilesProps> = (config, props) => {
const { targetName, widgets, groupIdentifier, keychainGroup, version, buildNumber } = props

Expand All @@ -53,6 +78,11 @@ export const generateWidgetExtensionFiles: ConfigPlugin<GenerateWidgetExtensionF
// Generate Assets.xcassets and copy user images
generateAssets({ targetPath })

// Copy ios-renderer.js bundle when any widget uses AppIntent (iOS 17+)
if (widgets?.some((w) => w.appIntent)) {
copyRendererBundle(targetPath, projectRoot)
}

// Generate Swift files (widget bundle, initial states)
await generateSwiftFiles({
targetPath,
Expand Down
Loading
Loading