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
20 changes: 20 additions & 0 deletions .changeset/client-rendered-widgets-android-experimental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@use-voltra/android-client': minor
---

**Experimental: client-rendered widgets (Android).** A widget component marked with the
`'use voltra'` directive now renders on-device in a standalone Hermes runtime, called as
`(props, env) => JSX` on every render, so it reacts to live environment values (widget size, color
scheme, locale, and configuration). Material You dynamic colors are consumed via
`AndroidDynamicColors` tokens that the native renderer resolves to the system color scheme (and that
follow light/dark automatically). In development the bundle is served by Metro and editing the JSX
hot-reloads the pinned widget; in release builds the bundle is baked into the app's assets at build
time.

Configuration parameters declared in `app.json` (`appIntent.parameters`, with code-defined
defaults) surface as `env.configuration`; runtime values set via `setWidgetConfiguration` override
the defaults (Android has no system widget-configuration UI, so this is an in-app stand-in).

This feature is **experimental** — usable in production at your own risk; the API and generated
build output may change. Release rendering has been verified on the Android emulator (the baked
bundle renders on-device with Metro stopped); confirming on a physical device is still recommended.
14 changes: 14 additions & 0 deletions .changeset/client-rendered-widgets-experimental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@use-voltra/ios-client': minor
---

**Experimental: client-rendered widgets (iOS).** A widget component marked with the `'use voltra'`
directive now renders on-device from its own JS bundle, called as `(props, env) => JSX` on every
render, so it reacts to live environment values (widget family, color scheme, locale, and
user-editable `configuration` via a native AppIntent "Edit Widget" sheet). In development the
bundle is served by Metro and editing the JSX hot-reloads the home-screen widget; in release builds
the bundle is baked into the widget extension at build time.

This feature is **experimental** — usable in production at your own risk; the API and generated
build output may change. Verify release rendering on a real device (the iOS Simulator is unreliable
for widget rendering).
7 changes: 7 additions & 0 deletions .changeset/twelve-widgets-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@use-voltra/compiler': minor
'@use-voltra/ios-client': minor
'@use-voltra/metro': minor
---

Add the Voltra compiler package for shared directive scanning, wire Metro and iOS prebuild validation to it, and keep the Metro scanner subpath as a re-export.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ npm-debug.log

## Build
/build
/packages/ios-client/ios/.build/
# SPM build caches under any iOS package (ios-client, voltra, etc.)
/packages/*/ios/.build/
3 changes: 2 additions & 1 deletion example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ expo-env.d.ts

# Metro
.metro-health-check*
.voltra/

# debug
npm-debug.*
Expand All @@ -37,4 +38,4 @@ yarn-error.*
*.tsbuildinfo

/ios
/android
/android
49 changes: 49 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@
"pl": "./widgets/ios/ios-weather-initial.tsx"
}
},
{
"id": "ClientRenderedDemoWidget",
"entry": "./widgets/ios/ClientRenderedDemoWidget.tsx",
"displayName": {
"en": "Client-Rendered Demo",
"pl": "Client-Rendered Demo"
},
"description": {
"en": "Plain widget showing env values — for verifying client-rendered hot reload.",
"pl": "Prosty widget pokazujący wartości env — do weryfikacji hot reload."
},
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/ios/ClientRenderedDemoWidget.tsx",
"appIntent": {
"parameters": [
{
"name": "label",
"title": "Label",
"default": "Hello"
}
]
}
},
{
"id": "portfolio",
"displayName": {
Expand Down Expand Up @@ -204,6 +227,32 @@
"intervalMinutes": 15,
"refresh": true
}
},
{
"id": "AndroidClientDemoWidget",
"entry": "./widgets/android/AndroidClientDemoWidget.tsx",
"displayName": {
"en": "Client Demo"
},
"description": {
"en": "Client-rendered Voltra widget (on-device Hermes)"
},
"minCellWidth": 2,
"minCellHeight": 2,
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen",
"initialStatePath": "./widgets/android/AndroidClientDemoWidget.tsx",
"appIntent": {
"parameters": [
{
"name": "label",
"title": "Label",
"default": "Hello"
}
]
}
}
],
"fonts": [
Expand Down
9 changes: 9 additions & 0 deletions example/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Stack } from 'expo-router'
import { Platform } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { enableWidgetHotReload as enableIosWidgetHotReload } from '@use-voltra/ios-client'
import { enableWidgetHotReload as enableAndroidWidgetHotReload } from '@use-voltra/android-client'
import '@use-voltra/widget-hot-reload'

import { useVoltraEvents } from '~/hooks/useVoltraEvents'
import { useServerDrivenWidgetToken } from '~/hooks/useServerDrivenWidgetToken'
import { updateAndroidVoltraWidget } from '~/widgets/android/updateAndroidVoltraWidget'

if (Platform.OS === 'android') {
enableAndroidWidgetHotReload()
} else {
enableIosWidgetHotReload()
}
updateAndroidVoltraWidget({ width: 300, height: 200 })

const STACK_SCREEN_OPTIONS = {
Expand Down
23 changes: 23 additions & 0 deletions example/metro.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const path = require('node:path')

const { getDefaultConfig } = require('expo/metro-config')
const { withVoltra } = require('@use-voltra/metro')

const config = getDefaultConfig(__dirname)
const repoRoot = path.resolve(__dirname, '..')

config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
'@use-voltra/android': path.join(repoRoot, 'packages/android'),
'@use-voltra/android-client': path.join(repoRoot, 'packages/android-client'),
'@use-voltra/core': path.join(repoRoot, 'packages/core'),
'@use-voltra/expo-plugin': path.join(repoRoot, 'packages/expo-plugin'),
'@use-voltra/ios': path.join(repoRoot, 'packages/ios'),
'@use-voltra/ios-client': path.join(repoRoot, 'packages/ios-client'),
'@use-voltra/server': path.join(repoRoot, 'packages/server'),
'~': __dirname,
}

config.watchFolders = Array.from(new Set([...(config.watchFolders || []), path.join(repoRoot, 'packages')]))

module.exports = withVoltra(config)
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"react-native-web": "^0.21.0",
"react-native-webview": "13.16.0",
"react-native-worklets": "~0.7.0",
"@use-voltra/metro": "workspace:*",
"@use-voltra/ios": "workspace:*",
"@use-voltra/ios-client": "workspace:*",
"@use-voltra/android": "workspace:*",
Expand Down
38 changes: 37 additions & 1 deletion example/screens/android/AndroidWidgetPinScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRouter } from 'expo-router'
import React, { useState } from 'react'
import { Alert, Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import { requestPinAndroidWidget } from '@use-voltra/android-client'
import { requestPinAndroidWidget, setWidgetConfiguration } from '@use-voltra/android-client'

import { Button } from '~/components/Button'
import { ScreenLayout } from '~/components/ScreenLayout'
Expand All @@ -28,6 +28,13 @@ const AVAILABLE_WIDGETS = [
defaultPreviewWidth: 250,
defaultPreviewHeight: 150,
},
{
id: 'AndroidClientDemoWidget',
name: 'Client-Rendered Demo',
description: 'On-device JSX render (Hermes) with live env',
defaultPreviewWidth: 250,
defaultPreviewHeight: 150,
},
]

export default function AndroidWidgetPinScreen() {
Expand All @@ -36,6 +43,19 @@ export default function AndroidWidgetPinScreen() {
const [previewWidth, setPreviewWidth] = useState<string>('250')
const [previewHeight, setPreviewHeight] = useState<string>('150')
const [isPinning, setIsPinning] = useState(false)
const [configLabel, setConfigLabel] = useState<string>('')

const handleSetConfig = async () => {
if (Platform.OS !== 'android') {
return
}
try {
await setWidgetConfiguration(selectedWidgetId, 'label', configLabel)
Alert.alert('Saved', `Set config "label" = "${configLabel}" for ${selectedWidgetId}. The widget re-renders.`)
} catch (error: any) {
Alert.alert('Error', error?.message || String(error))
}
}

const selectedWidget = AVAILABLE_WIDGETS.find((w) => w.id === selectedWidgetId) || AVAILABLE_WIDGETS[0]

Expand Down Expand Up @@ -110,6 +130,22 @@ export default function AndroidWidgetPinScreen() {
))}
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Configuration (client-rendered)</Text>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>env.configuration.label</Text>
<TextInput
style={styles.input}
value={configLabel}
onChangeText={setConfigLabel}
placeholder="label value"
placeholderTextColor="#64748B"
autoCapitalize="none"
/>
</View>
<Button title="Set configuration" onPress={handleSetConfig} style={styles.resetButton} />
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Preview Dimensions (optional)</Text>
<View style={styles.previewInputs}>
Expand Down
69 changes: 69 additions & 0 deletions example/widgets/android/AndroidClientDemoWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AndroidDynamicColors, VoltraAndroid, type WidgetEnvironment } from '@use-voltra/android'

// Minimal Dynamic Widget example for Android: its JSX runs on-device in Hermes on every render,
// receiving the live `env`. Edit the marker literal below and save — the home-screen widget
// updates via Fast Refresh (dev). Mirrors the iOS ClientRenderedDemoWidget. Themes itself from
// Material You via AndroidDynamicColors tokens, which the native renderer resolves to the system
// dynamic color scheme (and which follow light/dark automatically).

export default function AndroidClientDemoWidget(_props: object, env: WidgetEnvironment = {} as WidgetEnvironment) {
// ▼ EDIT THIS LITERAL TO TEST HOT RELOAD ▼
const hotReloadMarker = 'edit me'

const date = env.date ? new Date(env.date) : new Date()
const renderedAt = date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})

const config = env.configuration as Record<string, unknown> | undefined
const configLabel = typeof config?.label === 'string' ? config.label : '(unset)'

// Material You tokens — resolved natively from the system dynamic color scheme.
const bg = AndroidDynamicColors.surface
const fg = AndroidDynamicColors.onSurface
const muted = AndroidDynamicColors.onSurfaceVariant
const accent = AndroidDynamicColors.primary

const label = { fontSize: 10, color: muted } as const
const value = { fontSize: 10, color: fg } as const

const row = (k: string, v: string) => (
<VoltraAndroid.Row>
<VoltraAndroid.Text style={label}>{k} </VoltraAndroid.Text>
<VoltraAndroid.Text style={value}>{v}</VoltraAndroid.Text>
</VoltraAndroid.Row>
)

const swatch = (color: string) => (
<VoltraAndroid.Box style={{ width: 16, height: 16, backgroundColor: color, cornerRadius: 4 }} />
)

return (
<VoltraAndroid.Column
style={{ backgroundColor: bg, width: '100%', height: '100%', padding: 12 }}
verticalAlignment="center-vertically"
>
<VoltraAndroid.Text style={{ fontSize: 12, color: fg }}>Client-rendered demo</VoltraAndroid.Text>
<VoltraAndroid.Text style={{ fontSize: 14, color: accent }}>{hotReloadMarker}</VoltraAndroid.Text>
<VoltraAndroid.Spacer style={{ height: 6 }} />
{row('size:', env.widgetFamily ?? '?')}
{row('scheme:', env.colorScheme ?? '?')}
{row('locale:', env.locale ?? '?')}
{row('config:', configLabel)}
{row('time:', renderedAt)}
<VoltraAndroid.Spacer style={{ height: 8 }} />
<VoltraAndroid.Row>
{swatch(AndroidDynamicColors.primary)}
<VoltraAndroid.Spacer style={{ width: 6 }} />
{swatch(AndroidDynamicColors.secondary)}
<VoltraAndroid.Spacer style={{ width: 6 }} />
{swatch(AndroidDynamicColors.tertiary)}
<VoltraAndroid.Spacer style={{ width: 6 }} />
{swatch(AndroidDynamicColors.error)}
</VoltraAndroid.Row>
</VoltraAndroid.Column>
)
}
63 changes: 63 additions & 0 deletions example/widgets/ios/ClientRenderedDemoWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Voltra, type WidgetEnvironment } from '@use-voltra/ios'

// Minimal Dynamic Widget example for verifying the dev loop.
//
// Plain black tile with the env values the runtime captured per render, plus a single
// editable literal (`hotReloadMarker` below) for proving hot reload end-to-end.
// Edit the literal, save, watch the home-screen widget update within ~1 second.

export default function ClientRenderedDemoWidget(_props: object, env: WidgetEnvironment = {} as WidgetEnvironment) {
// ▼ EDIT THIS LITERAL TO TEST HOT RELOAD ▼
const hotReloadMarker = 'edit me'

const date = env.date ? new Date(env.date) : new Date()
const renderedAt = date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})

const config = env.configuration as Record<string, unknown> | undefined
const configLabel = typeof config?.label === 'string' ? config.label : '(unset)'

const labelStyle = { fontSize: 9, color: '#FFFFFF' } as const
const valueStyle = { fontSize: 9, color: '#94A3B8' } as const

return (
<Voltra.VStack alignment="leading" spacing={4} style={{ flex: 1, padding: 12, backgroundColor: '#000000' }}>
<Voltra.Text style={{ fontSize: 11, fontWeight: '700', color: '#FFFFFF' }}>Client-rendered demo</Voltra.Text>

<Voltra.Text style={{ fontSize: 14, fontWeight: '600', color: '#34D399' }}>{hotReloadMarker}</Voltra.Text>

<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>family:</Voltra.Text>
<Voltra.Text style={valueStyle}>{env.widgetFamily ?? '?'}</Voltra.Text>
</Voltra.HStack>
<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>scheme:</Voltra.Text>
<Voltra.Text style={valueStyle}>{env.colorScheme ?? '?'}</Voltra.Text>
</Voltra.HStack>
<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>mode:</Voltra.Text>
<Voltra.Text style={valueStyle}>{env.widgetRenderingMode ?? '?'}</Voltra.Text>
</Voltra.HStack>
<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>locale:</Voltra.Text>
<Voltra.Text style={valueStyle}>{env.locale ?? '?'}</Voltra.Text>
</Voltra.HStack>
<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>config:</Voltra.Text>
<Voltra.Text style={valueStyle}>{configLabel}</Voltra.Text>
</Voltra.HStack>
<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>dev:</Voltra.Text>
<Voltra.Text style={valueStyle}>{String(env.build?.isDev ?? '?')}</Voltra.Text>
</Voltra.HStack>
<Voltra.HStack spacing={4}>
<Voltra.Text style={labelStyle}>time:</Voltra.Text>
<Voltra.Text style={valueStyle}>{renderedAt}</Voltra.Text>
</Voltra.HStack>
</Voltra.VStack>
)
}
Loading
Loading