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/example/app.json b/example/app.json index e5b8050e..4dfab480 100644 --- a/example/app.json +++ b/example/app.json @@ -226,6 +226,31 @@ "intervalMinutes": 15, "refresh": true } + }, + { + "id": "AndroidClientDemoWidget", + "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 9970620d..20b3dee1 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,13 +1,19 @@ import { Stack } from 'expo-router' +import { Platform } from 'react-native' import { SafeAreaProvider } from 'react-native-safe-area-context' -import { enableWidgetHotReload } from '@use-voltra/ios-client' +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' -enableWidgetHotReload() +if (Platform.OS === 'android') { + enableAndroidWidgetHotReload() +} else { + enableIosWidgetHotReload() +} updateAndroidVoltraWidget({ width: 300, height: 200 }) const STACK_SCREEN_OPTIONS = { 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 + + +