diff --git a/.changeset/client-rendered-widgets-android-experimental.md b/.changeset/client-rendered-widgets-android-experimental.md new file mode 100644 index 00000000..8486dc9d --- /dev/null +++ b/.changeset/client-rendered-widgets-android-experimental.md @@ -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. diff --git a/.changeset/client-rendered-widgets-experimental.md b/.changeset/client-rendered-widgets-experimental.md new file mode 100644 index 00000000..f64459cf --- /dev/null +++ b/.changeset/client-rendered-widgets-experimental.md @@ -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). diff --git a/.changeset/twelve-widgets-walk.md b/.changeset/twelve-widgets-walk.md new file mode 100644 index 00000000..ca2c896a --- /dev/null +++ b/.changeset/twelve-widgets-walk.md @@ -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. diff --git a/.gitignore b/.gitignore index 34294cb3..7df4354e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/example/.gitignore b/example/.gitignore index d0e12830..aa83371d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -20,6 +20,7 @@ expo-env.d.ts # Metro .metro-health-check* +.voltra/ # debug npm-debug.* @@ -37,4 +38,4 @@ yarn-error.* *.tsbuildinfo /ios -/android \ No newline at end of file +/android diff --git a/example/app.json b/example/app.json index 752d545d..b09bb2b9 100644 --- a/example/app.json +++ b/example/app.json @@ -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": { @@ -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": [ diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 5b9e45a4..20b3dee1 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -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 = { diff --git a/example/metro.config.js b/example/metro.config.js new file mode 100644 index 00000000..7faad9a8 --- /dev/null +++ b/example/metro.config.js @@ -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) diff --git a/example/package.json b/example/package.json index 23ea1d9f..7095fd19 100644 --- a/example/package.json +++ b/example/package.json @@ -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:*", diff --git a/example/screens/android/AndroidWidgetPinScreen.tsx b/example/screens/android/AndroidWidgetPinScreen.tsx index f2771085..6cc24f74 100644 --- a/example/screens/android/AndroidWidgetPinScreen.tsx +++ b/example/screens/android/AndroidWidgetPinScreen.tsx @@ -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' @@ -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() { @@ -36,6 +43,19 @@ export default function AndroidWidgetPinScreen() { const [previewWidth, setPreviewWidth] = useState('250') const [previewHeight, setPreviewHeight] = useState('150') const [isPinning, setIsPinning] = useState(false) + const [configLabel, setConfigLabel] = useState('') + + 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] @@ -110,6 +130,22 @@ export default function AndroidWidgetPinScreen() { ))} + + Configuration (client-rendered) + + env.configuration.label + + +