diff --git a/website/docs/_meta.json b/website/docs/v1/_meta.json similarity index 100% rename from website/docs/_meta.json rename to website/docs/v1/_meta.json diff --git a/website/docs/_nav.json b/website/docs/v1/_nav.json similarity index 100% rename from website/docs/_nav.json rename to website/docs/v1/_nav.json diff --git a/website/docs/android/_meta.json b/website/docs/v1/android/_meta.json similarity index 100% rename from website/docs/android/_meta.json rename to website/docs/v1/android/_meta.json diff --git a/website/docs/v1/android/api/plugin-configuration.md b/website/docs/v1/android/api/plugin-configuration.md new file mode 100644 index 00000000..3defdb82 --- /dev/null +++ b/website/docs/v1/android/api/plugin-configuration.md @@ -0,0 +1,258 @@ +# Plugin Configuration (Android) + +The Voltra Expo config plugin accepts Android-specific configuration options in your `app.json` or `app.config.js`: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "groupIdentifier": "group.your.bundle.identifier", + "android": { + "enableNotifications": true, + "widgets": [ + { + "id": "weather", + "displayName": "Weather Widget", + "description": "Shows current weather conditions", + "targetCellWidth": 2, + "targetCellHeight": 2, + "initialStatePath": "./widgets/weather-initial.tsx", + "previewImage": "./assets/widgets/weather-preview.png" + } + ] + } + } + ] + ] + } +} +``` + +## Android-Specific Configuration + +### `android.enableNotifications` (optional) + +Enables Android notification-related manifest plumbing used by Voltra features such as ongoing notifications. + +When enabled, the config plugin adds: + +- `android.permission.POST_NOTIFICATIONS` +- `android.permission.POST_PROMOTED_NOTIFICATIONS` +- `voltra.VoltraOngoingNotificationDismissedReceiver` + +This does not grant runtime notification permission automatically. Your app still needs to request notification permission on Android 13 and above. + +For setup and usage examples, see [Managing Android Ongoing Notifications](../development/managing-ongoing-notifications). + +### `android.widgets` (optional) + +Array of widget configurations for Home Screen widgets. Each widget will be available in the Android widget picker. + +**Widget Configuration Properties:** + +- `id`: Unique identifier for the widget (alphanumeric with underscores only) +- `displayName`: Name shown in the widget picker +- `description`: Description shown in the widget picker +- `targetCellWidth`: Target widget width in grid cells (1-5, required) +- `targetCellHeight`: Target widget height in grid cells (1-5, required) +- `minCellWidth`: (optional) Minimum width in grid cells (defaults to targetCellWidth) +- `minCellHeight`: (optional) Minimum height in grid cells (defaults to targetCellHeight) +- `minWidth`: (optional) Minimum width in dp (overrides minCellWidth calculation) +- `minHeight`: (optional) Minimum height in dp (overrides minCellHeight calculation) +- `resizeMode`: (optional) Widget resize behavior (`"none"` | `"horizontal"` | `"vertical"` | `"horizontal|vertical"`, default: `"horizontal|vertical"`) +- `widgetCategory`: (optional) Widget category (`"home_screen"` | `"keyguard"` | `"home_screen|keyguard"`, default: `"home_screen"`) +- `initialStatePath`: (optional) Path to a file that exports initial widget state (see [Widget Pre-rendering](../development/widget-pre-rendering)) +- `previewImage`: (optional) Path to preview image for widget picker (PNG/JPG/WebP) +- `previewLayout`: (optional) Path to custom XML layout for widget picker preview (Android 12+) +- `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](../development/server-driven-widgets) for full details. + - `url`: The Voltra SSR endpoint URL + - `intervalMinutes`: Update interval in minutes (default: `15`, minimum 15 per WorkManager) + - `refresh`: Show a native refresh button (default: `false`) + +## Widget Sizing + +### Grid Cells vs Density-Independent Pixels (dp) + +Android uses grid cells to define widget sizes. By default, the formula is: +- **minWidth/minHeight (dp) = (cellCount × 70) - 30** + +**Example:** +- 2 cells = (2 × 70) - 30 = **110 dp** +- 4 cells = (4 × 70) - 30 = **250 dp** + +You can override this with explicit `minWidth` and `minHeight` in dp. + +### Standard Dimensions + +| Family | Cells | Default DP | Typical Use | +|--------|-------|-----------|-------------| +| Small | 2×1 | 110 × 40 | Quick glance info | +| Medium | 2×2 | 110 × 110 | Main widget size | +| Large | 4×2 | 250 × 110 | Rich content | +| Extra Large | 4×4 | 250 × 250 | Complex layouts | + +## Widget Picker Previews + +When users add a widget to their home screen, Android displays a preview in the widget picker. Voltra supports three preview methods, with automatic fallback: + +### Preview Priority Chain + +1. **`previewLayout`** (Android 12+) - Custom XML layout for scalable preview +2. **`previewImage`** (All versions) - Static image or auto-generated layout +3. **Default** - System placeholder layout + +### Using `previewImage` + +Static preview image for all Android versions: + +```json +{ + "widgets": [ + { + "id": "weather", + "displayName": "Weather Widget", + "targetCellWidth": 2, + "targetCellHeight": 2, + "previewImage": "./assets/widgets/weather-preview.png" + } + ] +} +``` + +When only `previewImage` is specified, Voltra automatically generates a layout that displays the image with proper scaling. + +### Using `previewLayout` + +Custom XML layout for scalable previews (Android 12+): + +```json +{ + "widgets": [ + { + "id": "todos", + "displayName": "Todo Widget", + "targetCellWidth": 2, + "targetCellHeight": 2, + "previewLayout": "./assets/widgets/todos-preview.xml" + } + ] +} +``` + +**Example `todos-preview.xml`:** + +```xml + + + + + + + + +``` + +The preview layout is rendered at the widget's target size and displayed in the widget picker. + +### Combined Preview Setup + +For best results across Android versions: + +```json +{ + "widgets": [ + { + "id": "weather", + "displayName": "Weather Widget", + "targetCellWidth": 2, + "targetCellHeight": 2, + "previewImage": "./assets/widgets/weather-preview.png", + "previewLayout": "./assets/widgets/weather-preview.xml", + "initialStatePath": "./widgets/weather-initial.tsx" + } + ] +} +``` + +This configuration: +- Uses `previewLayout` on Android 12+ (scalable, accurate preview) +- Falls back to `previewImage` on Android 11 and earlier +- Shows actual widget content on home screen via `initialStatePath` (when available) + +## Widget Pre-rendering + +Use `initialStatePath` to provide pre-rendered widget state: + +```json +{ + "widgets": [ + { + "id": "weather", + "displayName": "Weather Widget", + "targetCellWidth": 2, + "targetCellHeight": 2, + "initialStatePath": "./widgets/weather-initial.tsx" + } + ] +} +``` + +When the app is built, Voltra pre-renders the widget at the specified path and bundles it as `voltra_initial_states.json`. The widget displays this content immediately when first added to the home screen, before any dynamic updates. + +See [Widget Pre-rendering](../development/widget-pre-rendering) for details on creating initial state files. + +## Example Configuration + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "groupIdentifier": "group.com.example.app", + "android": { + "enableNotifications": true, + "widgets": [ + { + "id": "voltra", + "displayName": "Voltra Widget", + "description": "Voltra logo widget", + "minCellWidth": 2, + "minCellHeight": 2, + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen", + "initialStatePath": "./widgets/android-voltra-widget-initial.tsx", + "previewImage": "./assets/voltra-icon.jpg" + }, + { + "id": "interactive_todos", + "displayName": "Interactive Todos", + "description": "Quick todo list widget", + "targetCellWidth": 2, + "targetCellHeight": 2, + "previewLayout": "./assets/widgets/todos-preview.xml" + } + ] + } + } + ] + ] + } +} +``` diff --git a/website/docs/android/charts.md b/website/docs/v1/android/charts.md similarity index 100% rename from website/docs/android/charts.md rename to website/docs/v1/android/charts.md diff --git a/website/docs/android/components/_meta.json b/website/docs/v1/android/components/_meta.json similarity index 100% rename from website/docs/android/components/_meta.json rename to website/docs/v1/android/components/_meta.json diff --git a/website/docs/v1/android/components/interactive.md b/website/docs/v1/android/components/interactive.md new file mode 100644 index 00000000..8887ef8e --- /dev/null +++ b/website/docs/v1/android/components/interactive.md @@ -0,0 +1,97 @@ +# Interactive Controls (Android) + +User interface controls that respond to user interaction on Android widgets. + +### Button + +Standard button component. On Android, all buttons always open the application when clicked. You can provide a `deepLinkUrl` to open a specific screen. + +**Parameters:** + +- `enabled` (boolean, optional): Whether the button is enabled. +- `deepLinkUrl` (string, optional): URL to open when the button is clicked. If not provided, the app will open to its main activity. + +Voltra also provides specialized button variants: + +#### FilledButton +- `text` (string): Button label. +- `enabled` (boolean, optional). +- `deepLinkUrl` (string, optional). +- `icon` (object, optional): `{ assetName: string }`. +- `backgroundColor` (string, optional). +- `contentColor` (string, optional). +- `maxLines` (number, optional). + +#### OutlineButton +- `text` (string): Button label. +- `enabled` (boolean, optional). +- `deepLinkUrl` (string, optional). +- `icon` (object, optional): `{ assetName: string }`. +- `contentColor` (string, optional). +- `maxLines` (number, optional). + +#### CircleIconButton & SquareIconButton +- `enabled` (boolean, optional). +- `deepLinkUrl` (string, optional). +- `icon` (object, optional): `{ assetName: string, base64: string }`. +- `contentDescription` (string, optional). +- `backgroundColor` (string, optional). +- `contentColor` (string, optional). + +--- + +### Clickable Components + +Most components support being clickable by setting the `pressable` prop (short name `prs` in raw elements) in their props. + +**Parameters:** +- `pressable` (boolean): Set to `true` to make the component respond to clicks. +- `deepLinkUrl` (string, optional): URL to open when clicked. + +--- + +### Switch + +A toggle switch component. On Android, toggles always open the application when clicked. + +**Parameters:** + +- `checked` (boolean, optional): Current state of the switch. +- `deepLinkUrl` (string, optional): URL to open when clicked. +- `text` (string, optional): Label displayed next to the switch. +- `thumbCheckedColor` (string, optional). +- `thumbUncheckedColor` (string, optional). +- `trackCheckedColor` (string, optional). +- `trackUncheckedColor` (string, optional). +- `maxLines` (number, optional): Maximum lines for the label. + +--- + +### CheckBox + +Standard checkbox component. On Android, checkboxes always open the application when clicked. + +**Parameters:** + +- `checked` (boolean, optional). +- `deepLinkUrl` (string, optional). +- `text` (string, optional). +- `checkedColor` (string, optional). +- `uncheckedColor` (string, optional). +- `maxLines` (number, optional). + +--- + +### RadioButton + +Standard radio button component. On Android, radio buttons always open the application when clicked. + +**Parameters:** + +- `checked` (boolean, optional). +- `enabled` (boolean, optional). +- `deepLinkUrl` (string, optional). +- `text` (string, optional). +- `checkedColor` (string, optional). +- `uncheckedColor` (string, optional). +- `maxLines` (number, optional). diff --git a/website/docs/v1/android/components/layout.md b/website/docs/v1/android/components/layout.md new file mode 100644 index 00000000..55937ddf --- /dev/null +++ b/website/docs/v1/android/components/layout.md @@ -0,0 +1,91 @@ +# Layout & Containers (Android) + +Components that arrange other elements or provide structural grouping using Jetpack Compose Glance primitives. See [Styling](../development/styling) for details on layout and spacing properties. + +### Column + +A vertical container that arranges its children in a column. + +**Parameters:** + +- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. +- `verticalAlignment` (string, optional): `"top"`, `"center-vertically"`, `"bottom"`. + +--- + +### Row + +A horizontal container that arranges its children in a row. + +**Parameters:** + +- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. +- `verticalAlignment` (string, optional): `"top"`, `"center-vertically"`, `"bottom"`. + +--- + +### Box + +A container that stacks its children on top of each other. + +**Parameters:** + +- `contentAlignment` (string, optional): Combined alignment. Supports `"top-start"`, `"top-center"`, `"top-end"`, `"center-start"`, `"center"`, `"center-end"`, `"bottom-start"`, `"bottom-center"`, `"bottom-end"`. + +--- + +### Scaffold + +A top-level container that provides a standard layout structure for widgets. + +**Parameters:** + +- `backgroundColor` (string, optional): Background color for the scaffold. +- `horizontalPadding` (number, optional): Horizontal padding in dp. + +--- + +### TitleBar + +A component that displays a title bar with an optional icon. + +**Parameters:** + +- `title` (string): Title text to display. +- `startIcon` (object): `{ assetName: string }`. +- `textColor` (string, optional). +- `iconColor` (string, optional). +- `fontFamily` (string, optional). + +--- + +### Spacer + +A component that provides fixed spacing between elements. + +**Parameters:** + +- `size` (number): Size of the spacer in dp. + +--- + +### LazyColumn + +A scrollable vertical list that only renders visible items. + +**Parameters:** + +- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. + +--- + +### LazyVerticalGrid + +A scrollable grid of items. + +**Parameters:** + +- `columns` (number | `"adaptive"`): Number of columns or `"adaptive"` for an adaptive grid. +- `minSize` (number, optional): Minimum size (in dp) for items in adaptive grid mode. +- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. +- `verticalAlignment` (string, optional): `"top"`, `"center"`, `"bottom"`. diff --git a/website/docs/v1/android/components/status.md b/website/docs/v1/android/components/status.md new file mode 100644 index 00000000..a093f01d --- /dev/null +++ b/website/docs/v1/android/components/status.md @@ -0,0 +1,27 @@ +# Data Visualization & Status (Android) + +Components for displaying data and status information on Android widgets. + +### LinearProgressIndicator + +A horizontal progress bar. + +**Parameters:** + +- `progress` (number, optional): Current progress value (0.0 to 1.0). If omitted, the indicator will be indeterminate. +- `color` (string, optional): Color for the progress indicator. +- `backgroundColor` (string, optional): Color for the background track. + +--- + +### CircularProgressIndicator + +A circular progress indicator. + +**Parameters:** + +- `color` (string, optional): Color for the progress indicator. + +:::warning Indeterminate Only +Due to Jetpack Compose Glance limitations, the `CircularProgressIndicator` on Android only supports **indeterminate** mode. The `progress` property is ignored. +::: diff --git a/website/docs/v1/android/components/visual.md b/website/docs/v1/android/components/visual.md new file mode 100644 index 00000000..4ebca048 --- /dev/null +++ b/website/docs/v1/android/components/visual.md @@ -0,0 +1,45 @@ +# Visual Elements & Typography (Android) + +Static or decorative elements used to display content on Android widgets. See [Styling](../development/styling) for details on supported style properties. + +### Text + +Displays text content. + +**Parameters:** + +- `maxLines` (number, optional): Maximum number of lines to display. +- `renderAsBitmap` (boolean, optional): Renders text as a bitmap image to enable [custom fonts](../development/custom-fonts). Requires `fontFamily` in the style prop. + +--- + +### Image + +Displays bitmap images from the asset catalog or base64 encoded data. + +**Parameters:** + +- `source` (object, optional): Image source object. + - `assetName` (string): Reference to a pre-bundled image (drawable resource) or a [preloaded image](../development/image-preloading). + - `base64` (string): Base64 encoded image data. +- `resizeMode` (string, optional): `"cover"`, `"contain"`, `"stretch"`, `"repeat"`, or `"center"`. +- `contentScale` (string, optional): Glance-specific terminology for resize mode: `"crop"`, `"fit"`, `"fill-bounds"`. +- `contentDescription` (string, optional): Accessibility description for the image. +- `alpha` (number, optional): Opacity value from 0.0 to 1.0. +- `tintColor` (string, optional): Color to tint the image with. +- `fallback` (ReactNode, optional): Custom content rendered when the image is missing. + +**Styling the fallback:** + +To add a background color when an image is missing, use `backgroundColor` in the `style` prop: + +```jsx + +``` + +:::tip Image Preloading +For dynamic images from remote URLs, use the [Image Preloading](../development/image-preloading) API to cache them locally for use in widgets. +::: diff --git a/website/docs/android/development/_meta.json b/website/docs/v1/android/development/_meta.json similarity index 100% rename from website/docs/android/development/_meta.json rename to website/docs/v1/android/development/_meta.json diff --git a/website/docs/v1/android/development/custom-fonts.md b/website/docs/v1/android/development/custom-fonts.md new file mode 100644 index 00000000..2d3692ef --- /dev/null +++ b/website/docs/v1/android/development/custom-fonts.md @@ -0,0 +1,91 @@ +# Custom Fonts + +Android Glance only supports a handful of built-in font families (`monospace`, `serif`, `sans-serif`, `cursive`). Voltra works around this by rendering text as a bitmap with a custom `Typeface` loaded from `assets/fonts/`. + +## Setup + +### 1. Add font files to the plugin config + +List your font paths in the top-level `fonts` array. These can be local files or packages from `@expo-google-fonts`: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "fonts": [ + "node_modules/@expo-google-fonts/pacifico/400Regular/Pacifico_400Regular.ttf", + "./assets/fonts/MyCustomFont.ttf" + ], + "android": { "widgets": [...] } + } + ] + ] + } +} +``` + +### 2. Run prebuild + +```bash +npx expo prebuild +``` + +The plugin copies each font file to `android/app/src/main/assets/fonts/` automatically. + +### 3. Use `renderAsBitmap` on Text + +```tsx +import { VoltraAndroid } from 'voltra/android' + + + Hello Voltra! + +``` + +The `fontFamily` value should match the font filename **without the extension**. + +## How it works + +When `renderAsBitmap` is set and `fontFamily` is provided in the style: + +1. The font is loaded via `Typeface.createFromAsset()` (cached with an LRU cache) +2. Text is drawn to an Android `Canvas` bitmap using `StaticLayout` +3. The bitmap is displayed as a Glance `Image` with fixed dp dimensions + +This means the text is rasterized — it won't respond to system font size settings. Use it only when a custom typeface is needed. + +## Supported style properties + +When rendering as bitmap, the following text style properties are supported: + +| Property | Description | +|----------|-------------| +| `fontSize` | Font size in sp (scaled to device density) | +| `fontFamily` | Font filename without extension | +| `fontWeight` | `"normal"` or `"bold"` | +| `color` | Text color | +| `textAlign` | `"left"`, `"center"`, `"right"` | +| `textDecorationLine` | `"underline"`, `"line-through"` | +| `letterSpacing` | Letter spacing value | +| `lineHeight` | Line spacing | + +## Built-in font families + +For built-in families you don't need `renderAsBitmap` — use `fontFamily` in style directly: + +- `monospace` +- `serif` +- `sans-serif` +- `cursive` + +These are passed through to Glance's native `FontFamily` API. diff --git a/website/docs/v1/android/development/developing-widgets.md b/website/docs/v1/android/development/developing-widgets.md new file mode 100644 index 00000000..7384b370 --- /dev/null +++ b/website/docs/v1/android/development/developing-widgets.md @@ -0,0 +1,76 @@ +# Developing Android Widgets + +Voltra allows you to build Android Home Screen widgets using JSX and Jetpack Compose Glance primitives. + +## Glance Primitives + +On Android, you use `VoltraAndroid` components which map to Glance primitives: + +- **Column:** Vertical layout +- **Row:** Horizontal layout +- **Box:** Stacked layout +- **Spacer:** Flexible spacing +- **Text:** Displaying text +- **Image:** Displaying images +- **Scaffold:** Top-level container + +### Example Widget + +```tsx +import { VoltraAndroid } from 'voltra' + +const WeatherWidget = ({ temperature, condition }) => ( + + + + {temperature}°C + + + {condition} + + + +) +``` + +## Update API + +To update a widget's content, use the `updateWidget` function from `voltra/client`: + +```typescript +import { updateWidget } from 'voltra/client' + +await updateWidget('weather_widget', ) +``` + +## Layout Constraints + +Unlike standard React Native or iOS Stacks, Android Glance layouts are more restrictive: +- **Width/Height:** Use fixed numbers (dp), `"100%"` to fill available space, or `"auto"` to wrap content. +- **Modifiers:** Most styling is handled via the `style` prop, which maps to Glance `Modifier`s. See [Styling](./styling) for full details. +- **Alignment:** Use `verticalAlignment` and `horizontalAlignment` props on `Column` and `Row`. + +## Advanced Features + +- **[Querying Active Widgets](./querying-active-widgets):** Detect active widget instances and their sizes. +- **[Testing and Previews](./testing-and-previews):** Preview layouts within your app. +- **[Widget Picker Previews](../api/plugin-configuration#widget-picker-previews):** Configure how your widget appears in the Android widget picker. +- **[Image Preloading](./image-preloading):** Cache remote images for use in widgets. +- **[Widget Pre-rendering](./widget-pre-rendering):** Provide initial state for widgets before the app first runs. + +## Widget Picker Previews + +When users browse the widget picker to add your widget to their home screen, they see a preview. You can customize this preview using: + +- **`previewImage`:** Static image (PNG/JPG/WebP) that shows in the picker on all Android versions +- **`previewLayout`:** Custom XML layout that renders a scalable preview on Android 12+ + +See [Plugin Configuration - Widget Picker Previews](../api/plugin-configuration#widget-picker-previews) for configuration details and examples. diff --git a/website/docs/v1/android/development/dynamic-colors.md b/website/docs/v1/android/development/dynamic-colors.md new file mode 100644 index 00000000..e7fddbae --- /dev/null +++ b/website/docs/v1/android/development/dynamic-colors.md @@ -0,0 +1,124 @@ +# Dynamic colors + +Voltra supports Android dynamic colors through semantic tokens exposed from `voltra/android`. + +These colors follow the current Android Material palette, so widgets can pick up wallpaper and theme changes without waiting for JavaScript to run again. + +## Importing dynamic colors + +```tsx +import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android' +``` + +`AndroidDynamicColors` includes these roles: + +- `primary` +- `onPrimary` +- `primaryContainer` +- `onPrimaryContainer` +- `secondary` +- `onSecondary` +- `secondaryContainer` +- `onSecondaryContainer` +- `tertiary` +- `onTertiary` +- `tertiaryContainer` +- `onTertiaryContainer` +- `error` +- `errorContainer` +- `onError` +- `onErrorContainer` +- `background` +- `onBackground` +- `surface` +- `onSurface` +- `surfaceVariant` +- `onSurfaceVariant` +- `outline` +- `inverseOnSurface` +- `inverseSurface` +- `inversePrimary` +- `widgetBackground` + +## Example + +```tsx +import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android' + +export function WeatherWidget() { + return ( + + + + 21° + + + + + + ) +} +``` + +## Where you can use them + +You can use `AndroidDynamicColors.*` anywhere Android accepts a color value, including: + +- `style.backgroundColor` +- `style.color` +- `Image.tintColor` +- button `backgroundColor` and `contentColor` +- `TitleBar.textColor` and `TitleBar.iconColor` +- switch, checkbox, and radio button colors +- progress indicator colors +- chart mark colors + +## Server-driven widgets + +Dynamic color tokens work in server-rendered Android widgets too. + +```tsx +import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android' + +const content = ( + + + Server-rendered widget + + +) +``` + +The same `AndroidDynamicColors.*` values work whether the widget is rendered in-app or returned from your server. + +## Migration notes + +Voltra no longer uses the old Android dynamic palette snapshot approach. + +- Use `AndroidDynamicColors.*` for Android widgets that should react to system palette changes. +- Keep using literal colors when you want a fixed color. +- There is no `useAndroidDynamicColorPalette()` or `getAndroidDynamicColorPalette()` API anymore. diff --git a/website/docs/v1/android/development/image-preloading.md b/website/docs/v1/android/development/image-preloading.md new file mode 100644 index 00000000..7998acad --- /dev/null +++ b/website/docs/v1/android/development/image-preloading.md @@ -0,0 +1,96 @@ +# Image Preloading (Android) + +Android widgets have limitations when it comes to displaying remote images directly. The image preloading API allows you to download images to the app's cache directory, making them available to your widgets via a local `FileProvider`. + +## Overview + +The image preloading system on Android works by: + +1. Downloading images from URLs to the internal app cache. +2. Making these images available to Voltra widgets via the `assetName` property. +3. Providing APIs to reload widgets when new images are ready. + +## API Reference + +### `preloadImages(images: PreloadImageOptions[]): Promise` + +Downloads images to the Android cache for use in Widgets. + +```typescript +type PreloadImageOptions = { + url: string // The URL to download the image from + key: string // The assetName to use when referencing this image + method?: 'GET' | 'POST' | 'PUT' // HTTP method (default: 'GET') + headers?: Record // Optional HTTP headers +} + +type PreloadImagesResult = { + succeeded: string[] // Keys of successfully downloaded images + failed: { key: string; error: string }[] // Failed downloads with error messages +} +``` + +**Example:** + +```typescript +import { preloadImages } from 'voltra/android' + +const result = await preloadImages([ + { + url: 'https://example.com/album-art.jpg', + key: 'current-album', + headers: { Authorization: 'Bearer token' }, + }, +]) + +if (result.succeeded.includes('current-album')) { + // Images are ready to be used in widgets +} +``` + +### `reloadWidgets(widgetIds?: string[]): Promise` + +Reloads Android widgets to pick up newly preloaded images. If no `widgetIds` are provided, all active widgets will be reloaded. + +```typescript +import { reloadWidgets } from 'voltra/android' + +// Reload all widgets +await reloadWidgets() + +// Reload specific widgets +await reloadWidgets(['weather_widget']) +``` + +### `clearPreloadedImages(keys?: string[]): Promise` + +Removes preloaded images from the Android cache. If no `keys` are provided, all preloaded images will be cleared. + +```typescript +import { clearPreloadedImages } from 'voltra/android' + +// Clear specific images +await clearPreloadedImages(['current-album']) + +// Clear all preloaded images +await clearPreloadedImages() +``` + +## Usage in Android Widgets + +Once images are preloaded, reference them using the `assetName` property in the `VoltraAndroid.Image` component: + +```tsx +import { VoltraAndroid } from 'voltra' + +function MusicWidget({ albumKey }) { + return ( + + + + ) +} +``` diff --git a/website/docs/v1/android/development/images.md b/website/docs/v1/android/development/images.md new file mode 100644 index 00000000..e4539db4 --- /dev/null +++ b/website/docs/v1/android/development/images.md @@ -0,0 +1,122 @@ +# Images + +Voltra provides three different approaches for including images in your Android widgets, each with different trade-offs and use cases: + +- **Build-time asset copying**: Best for static icons and assets known at build time +- **Runtime preloading**: Best for dynamic images from remote URLs +- **Base64 encoding**: Best for small, generated images + +## Build-time asset copying + +Place images in the `/assets/voltra-android/` directory and they'll be automatically processed and copied to the Android drawable resources during build. + +``` +project-root/ +├── assets/ +│ └── voltra-android/ +│ ├── logo.png +│ ├── weather/ +│ │ ├── sunny.svg +│ │ └── rainy.xml +│ └── background.webp +``` + +Here's how build-time asset copying works: + +1. Images in `/assets/voltra-android/` are automatically detected during build (run `npx expo prebuild` to apply changes). +2. Filenames are sanitized to be compatible with Android resource naming rules (lowercase, underscores only). +3. SVGs are automatically converted to Android Vector Drawables (XML). +4. Images are copied to `res/drawable/` in the native Android project. + +### Naming & Sanitization + +Android drawable resources have strict naming conventions. Voltra automatically handles this for you: + +- **Sanitization:** Uppercase letters are converted to lowercase. Hyphens and other special characters are replaced with underscores. +- **Flattening:** Subdirectories are flattened into the resource name to avoid conflicts. + +**Examples:** + +| Source File | Android Resource Name | +| :--- | :--- | +| `assets/voltra-android/Logo.png` | `logo` | +| `assets/voltra-android/icons/My-Icon.png` | `icons_my_icon` | +| `assets/voltra-android/weather/sunny.svg` | `weather_sunny` | + +### Supported Formats + +- **Bitmap:** `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp` +- **Vector:** `.svg` (converted to Vector Drawable), `.xml` (native Vector Drawable) + +### Usage + +Reference these images using their sanitized name in the `assetName` property. You do not need to include the file extension. + +```tsx +import { VoltraAndroid } from 'voltra' + +// assets/voltra-android/logo.png -> "logo" + + +// assets/voltra-android/weather/sunny.svg -> "weather_sunny" + +``` + +## Runtime preloading + +For dynamic images from remote URLs, use Voltra's image preloading API to cache images locally. + +The image preloading system works by: + +1. Downloading images from URLs to the app's internal cache. +2. Making images available to widgets via a local content provider. +3. Providing APIs to reload widgets when new images are ready. + +Once images are preloaded, reference them using the key you provided: + +```tsx +import { VoltraAndroid } from 'voltra' + +function ProfileWidget({ user }) { + return ( + + + + {user.name} + + + ) +} +``` + +For detailed API documentation, see [Image Preloading](./image-preloading). + +## Base64 encoding + +You can also embed images directly as base64-encoded strings. This is useful for small, generated images or when you want to avoid file management for very simple assets. + +```tsx + +``` + +## Comparison table + +| Approach | When Known | Dynamic | Setup Required | Performance | +| :--- | :--- | :--- | :--- | :--- | +| **Build-time** | Build time | No | File placement | Best (Native Resource) | +| **Preloading** | Runtime | Yes | Preload API call | Good (Cached File) | +| **Base64** | Runtime/Build | No | None | Fair (Memory intensive if large) | diff --git a/website/docs/v1/android/development/managing-ongoing-notifications.md b/website/docs/v1/android/development/managing-ongoing-notifications.md new file mode 100644 index 00000000..06dc5482 --- /dev/null +++ b/website/docs/v1/android/development/managing-ongoing-notifications.md @@ -0,0 +1,524 @@ +# Managing Android Ongoing Notifications + +:::warning Experimental API +Android ongoing notifications are **experimental**. The API may change in future releases. +::: + +Voltra supports Android ongoing notifications for local, app-driven status updates such as deliveries, rides, workouts, or timers. + +Use this API when you want to: + +- start a persistent notification from your app +- update its content over time +- stop it when the task ends +- add action buttons that open deep links in your app + +Voltra also supports remote updates if your app receives push notifications in the background and forwards the payload to the ongoing notification APIs. + +## Server-side rendering support + +Voltra already provides a server-side API for converting JSX into the semantic payload used by Android ongoing notifications. + +Use these APIs only in server-side or backend code. Do not import them from your React Native app runtime. + +Use `voltra/android/server`. + +The main renderer APIs are: + +- `renderAndroidOngoingNotificationPayloadToJson()` returns an object +- `renderAndroidOngoingNotificationPayload()` returns a JSON string + +This API only renders the payload. Your server still needs to send that payload through your push provider, and your app still needs a background task that calls `upsertAndroidOngoingNotification()` or `stopAndroidOngoingNotification()` when the push arrives. + +## Before you start + +### 1. Enable notification manifest support + +Add `android.enableNotifications` to the Voltra Expo plugin config: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "android": { + "enableNotifications": true + } + } + ] + ] + } +} +``` + +This adds the Android manifest entries required by Voltra's notification features. + +See [Plugin Configuration](../api/plugin-configuration#androidenablenotifications-optional) for details. + +### 2. Create a notification channel + +`channelId` is required when starting an ongoing notification, and the channel must already exist. + +If you use `expo-notifications`, you can create a channel like this: + +```tsx +import * as Notifications from 'expo-notifications' + +await Notifications.setNotificationChannelAsync('delivery_updates', { + name: 'Delivery updates', + importance: Notifications.AndroidImportance.DEFAULT, +}) +``` + +### 3. Request notification permission on Android 13+ + +On Android 13 and above, posting notifications requires runtime permission. + +```tsx +import { + hasAndroidNotificationPermission, + requestAndroidNotificationPermission, +} from 'voltra/android/client' + +const granted = + (await hasAndroidNotificationPermission()) || (await requestAndroidNotificationPermission()) + +if (!granted) { + // Show your own UI explaining why notifications are needed. +} +``` + +### 4. If you want remote updates, register a background notification task + +The playground app uses `expo-notifications` together with `expo-task-manager` to process real push notifications and update ongoing notifications in the background. + +Register a background task early in app startup: + +```tsx +import * as Notifications from 'expo-notifications' +import * as TaskManager from 'expo-task-manager' + +const TASK_NAME = 'voltra-ongoing-notification-task' + +TaskManager.defineTask(TASK_NAME, async ({ data, error }) => { + if (error) { + return + } + + // Read your push payload and call Voltra APIs here. +}) + +await Notifications.registerTaskAsync(TASK_NAME) +``` + +The example app does this during startup so that incoming pushes can update or stop an ongoing notification even when the app is backgrounded. + +## Starting a notification + +Voltra provides two built-in layouts: + +- `AndroidOngoingNotification.Progress` +- `AndroidOngoingNotification.BigText` + +### Progress notification + +```tsx +import { + AndroidOngoingNotification, + startAndroidOngoingNotification, +} from 'voltra/android/client' + +const result = await startAndroidOngoingNotification( + , + { + notificationId: 'order-123', + channelId: 'delivery_updates', + deepLinkUrl: 'myapp://orders/123', + } +) + +if (result.ok) { + console.log('Started:', result.notificationId) +} +``` + +### Big text notification + +```tsx +import { + AndroidOngoingNotification, + startAndroidOngoingNotification, +} from 'voltra/android/client' + +await startAndroidOngoingNotification( + , + { + notificationId: 'match-42', + channelId: 'sports_updates', + } +) +``` + +## Updating a notification + +Use the same `notificationId` to update an existing notification. + +```tsx +import { + AndroidOngoingNotification, + updateAndroidOngoingNotification, +} from 'voltra/android/client' + +await updateAndroidOngoingNotification( + 'order-123', + +) +``` + +`updateAndroidOngoingNotification()` returns a result object. If the notification no longer exists, it returns `reason: 'not_found'` or `reason: 'dismissed'`. + +## Starting or updating with one call + +If your app may re-enter the same flow multiple times, `upsertAndroidOngoingNotification()` can be easier than separate start/update logic. + +```tsx +import { + AndroidOngoingNotification, + upsertAndroidOngoingNotification, +} from 'voltra/android/client' + +const result = await upsertAndroidOngoingNotification( + , + { + notificationId: 'workout-1', + channelId: 'fitness_updates', + } +) + +if (result.ok) { + console.log(result.action) // 'started' or 'updated' +} +``` + +This API is especially useful for remote updates, where the same incoming push may need to create the notification the first time and update it later. + +## Stopping a notification + +```tsx +import { stopAndroidOngoingNotification } from 'voltra/android/client' + +await stopAndroidOngoingNotification('order-123') +``` + +To dismiss every active Voltra ongoing notification at once: + +```tsx +import { endAllAndroidOngoingNotifications } from 'voltra/android/client' + +await endAllAndroidOngoingNotifications() +``` + +## Hook API + +For React screens and flows, use `useAndroidOngoingNotification()`. + +```tsx +import { AndroidOngoingNotification } from 'voltra/android' +import { useAndroidOngoingNotification } from 'voltra/android/client' + +function DeliveryNotification({ orderId, etaMinutes }) { + const { start, update, end, isActive } = useAndroidOngoingNotification( + , + { + notificationId: `order-${orderId}`, + channelId: 'delivery_updates', + deepLinkUrl: `myapp://orders/${orderId}`, + autoStart: true, + autoUpdate: true, + } + ) + + return null +} +``` + +The hook returns: + +- `start()` +- `update()` +- `end()` +- `isActive` + +Use `autoStart` to create the notification when the component mounts, and `autoUpdate` to refresh it when the JSX content changes. + +## Action buttons + +You can add action buttons as children of `Progress` or `BigText`. + +```tsx +import { AndroidOngoingNotification } from 'voltra/android/client' + + + + + +``` + +Action buttons currently: + +- open the provided deep link +- can be used with `Progress` and `BigText` +- support an optional `icon` + +```tsx + +``` + +Android may not show action icons in the standard notification UI, so treat them as optional enhancement rather than a guaranteed visual element. + +## Remote updates + +Voltra can apply remote ongoing-notification updates if your app receives a push notification and handles it in a background task. + +The end-to-end flow is: + +1. Your server renders Voltra JSX into an Android ongoing-notification payload. +2. Your server sends a high-priority push notification. +3. The push `data` contains a `voltraOngoingNotification` object. +4. Your background task parses that object. +5. The task calls `upsertAndroidOngoingNotification()` or `stopAndroidOngoingNotification()`. + +### 1. Render the payload on your server + +Use `renderAndroidOngoingNotificationPayloadToJson()` when preparing a payload on your server or in app tooling: + +```tsx +import { + AndroidOngoingNotification, + renderAndroidOngoingNotificationPayloadToJson, +} from 'voltra/android/server' + +const payload = renderAndroidOngoingNotificationPayloadToJson( + + + +) +``` + +Then send that payload inside a push message. + +If your push provider expects strings for nested payload data, use `renderAndroidOngoingNotificationPayload()` instead and send the JSON string directly. + +### 2. Send the payload through your push provider + +The playground app expects `data.voltraOngoingNotification` to contain: + +- `notificationId`: the stable notification identifier +- `operation`: `'upsert'` or `'stop'` +- `options`: start options such as `channelId`, `smallIcon`, `deepLinkUrl`, `requestPromotedOngoing`, or `fallbackBehavior` +- `payload`: the Voltra semantic payload for `'upsert'` + +Example Expo push request: + +```json +{ + "to": "ExponentPushToken[project-token]", + "priority": "high", + "data": { + "voltraOngoingNotification": "{\"notificationId\":\"order-123\",\"operation\":\"upsert\",\"options\":{\"channelId\":\"delivery_updates\",\"deepLinkUrl\":\"myapp://orders/123\",\"requestPromotedOngoing\":true},\"payload\":{\"v\":1,\"kind\":\"progress\",\"title\":\"Driver is approaching\",\"text\":\"2 stops away\",\"value\":80,\"max\":100}}" + } +} +``` + +The playground app accepts either an object or a JSON string for `data.voltraOngoingNotification`. Stringifying it is often the safest option when sending through push providers. + +To stop the notification remotely, send the same `notificationId` with `operation: "stop"` and omit `payload`. + +### 3. Apply the payload in your background task + +```tsx +import * as Notifications from 'expo-notifications' +import * as TaskManager from 'expo-task-manager' +import { + stopAndroidOngoingNotification, + upsertAndroidOngoingNotification, +} from 'voltra/android/client' + +const TASK_NAME = 'voltra-ongoing-notification-task' + +const parseMessage = (value: unknown) => { + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch { + return null + } + } + + return value +} + +TaskManager.defineTask(TASK_NAME, async ({ data, error }) => { + if (error) { + return + } + + const message = parseMessage(data?.voltraOngoingNotification) + if (!message || typeof message !== 'object') { + return + } + + const notificationId = typeof message.notificationId === 'string' ? message.notificationId : null + if (!notificationId) { + return + } + + if (message.operation === 'stop') { + await stopAndroidOngoingNotification(notificationId) + return + } + + if (!message.payload || !message.options?.channelId) { + return + } + + await upsertAndroidOngoingNotification(message.payload, { + ...message.options, + notificationId, + }) +}) + +await Notifications.registerTaskAsync(TASK_NAME) +``` + +### Channel setup for remote updates + +Your background task should ensure that the target notification channel exists before calling `upsertAndroidOngoingNotification()`. The playground app creates the channel on startup and also ensures it exists again inside the background handler. + +### Important notes + +- Voltra does include a server-side JSX-to-payload renderer for Android ongoing notifications. +- Remote updates depend on your push provider and app-level background notification setup. +- Voltra provides the ongoing-notification rendering and lifecycle APIs, but your app is responsible for receiving the push and invoking those APIs. +- `upsertAndroidOngoingNotification()` is the easiest entry point for remote updates because it can create or update the notification with the same payload path. +- If your push provider serializes nested objects as strings, parse `data.voltraOngoingNotification` before passing it to Voltra. + +## Main tap behavior + +Use `deepLinkUrl` in the start or update options to control what happens when the user taps the main notification body: + +```tsx +await startAndroidOngoingNotification(content, { + notificationId: 'order-123', + channelId: 'delivery_updates', + deepLinkUrl: 'myapp://orders/123', +}) +``` + +This is separate from action button deep links. + +## Status and capability helpers + +Use these helpers to adapt your UI to the device state: + +```tsx +import { + canPostPromotedAndroidNotifications, + getAndroidOngoingNotificationCapabilities, + getAndroidOngoingNotificationStatus, + openAndroidNotificationSettings, +} from 'voltra/android/client' + +const status = getAndroidOngoingNotificationStatus('order-123') +const capabilities = getAndroidOngoingNotificationCapabilities() +const canPostPromoted = canPostPromotedAndroidNotifications() + +if (!capabilities.notificationsEnabled) { + await openAndroidNotificationSettings() +} +``` + +Useful values include: + +- `status.isActive` +- `status.isDismissed` +- `capabilities.notificationsEnabled` +- `capabilities.supportsPromotedNotifications` +- `capabilities.canPostPromotedNotifications` +- `capabilities.canRequestPromotedOngoing` + +## Promoted ongoing notifications + +If your app wants to request promoted ongoing presentation when the device supports it, pass `requestPromotedOngoing: true`: + +```tsx +await startAndroidOngoingNotification(content, { + notificationId: 'ride-44', + channelId: 'ride_updates', + requestPromotedOngoing: true, +}) +``` + +You can also set `fallbackBehavior` if promoted presentation is unavailable: + +```tsx +await startAndroidOngoingNotification(content, { + notificationId: 'ride-44', + channelId: 'ride_updates', + requestPromotedOngoing: true, + fallbackBehavior: 'standard', +}) +``` + +Check device support first with `getAndroidOngoingNotificationCapabilities()` if you want to tailor the UX. + +## Current limitations + +- Remote updates require your own push delivery and background task integration. +- Your app must create the Android notification channel before starting a notification. +- Notification permission still needs to be requested by your app on Android 13+. +- Action buttons open deep links. They are not a JavaScript event system. diff --git a/website/docs/v1/android/development/querying-active-widgets.md b/website/docs/v1/android/development/querying-active-widgets.md new file mode 100644 index 00000000..624c19f9 --- /dev/null +++ b/website/docs/v1/android/development/querying-active-widgets.md @@ -0,0 +1,36 @@ +# Querying Active Widgets + +On Android, you can detect every active instance of your widgets currently placed on the Home Screen. This is particularly useful for Android since each widget instance can have different dimensions and a unique `widgetId`. + +## getActiveWidgets API + +The `getActiveWidgets` function returns a promise that resolves to an array of all active widget instances for your app. + +```typescript +import { getActiveWidgets } from 'voltra/android' + +async function checkAndroidWidgets() { + const activeWidgets = await getActiveWidgets() + + console.log(`Found ${activeWidgets.length} active widget instances`) + + activeWidgets.forEach(widget => { + console.log(`- Widget Name: ${widget.name}`) + console.log(` ID: ${widget.widgetId}`) + console.log(` Size: ${widget.width}x${widget.height}dp`) + }) +} +``` + +### WidgetInfo Object + +Each object in the returned array contains: + +| Property | Type | Description | +| :--- | :--- | :--- | +| `name` | `string` | The unique ID of the widget as defined in your Expo config plugin (e.g., `"weather"`). | +| `widgetId` | `number` | The unique system identifier for this specific widget instance. | +| `providerClassName` | `string` | The full class name of the widget provider (e.g., `".widget.VoltraWidget_weatherReceiver"`). | +| `label` | `string` | The human-readable label shown in the Android widget picker. | +| `width` | `number` | The current width of the widget instance in dp. | +| `height` | `number` | The current height of the widget instance in dp. | diff --git a/website/docs/v1/android/development/server-driven-widgets.md b/website/docs/v1/android/development/server-driven-widgets.md new file mode 100644 index 00000000..9740c2d9 --- /dev/null +++ b/website/docs/v1/android/development/server-driven-widgets.md @@ -0,0 +1,289 @@ +# Server-driven widgets + +Server-driven widgets allow your Android Home Screen widgets to periodically fetch fresh content from a remote server—without the user opening the app. This is powered by WorkManager, which handles scheduling, retries, and network constraints automatically. + +Before you start, make sure the widget is registered in the Voltra plugin config and plan to rebuild the native app after adding or changing server-driven widget settings. + +Android semantic color tokens from [`AndroidDynamicColors`](./dynamic-colors) work in server-rendered widgets too, so your backend can return dynamic Material roles instead of fixed hex values. + +## How it works + +1. You configure a `serverUpdate` URL in your Android widget's plugin config +2. WorkManager runs a periodic background task at the configured interval +3. Your server renders Voltra JSX components into a JSON payload +4. The worker parses the payload and pushes a `RemoteViews` update to the widget + +Your app doesn't need to be running. WorkManager handles everything in the background. + +## Plugin configuration + +Add the `serverUpdate` option to your Android widget in `app.json` or `app.config.js`: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "android": { + "widgets": [ + { + "id": "dynamic_weather", + "displayName": "Dynamic Weather", + "description": "Weather with live server updates", + "targetCellWidth": 2, + "targetCellHeight": 1, + "serverUpdate": { + "url": "https://api.example.com/widgets/render", + "intervalMinutes": 60 + } + } + ] + } + } + ] + ] + } +} +``` + +**`serverUpdate` options:** + +- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=android&theme=dark`). +- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. The minimum effective interval is 15 minutes (WorkManager requirement). +- `refresh`: Whether to show a native refresh button in the top-right corner of the widget. When tapped, triggers an immediate server fetch. Defaults to `false`. + +After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native widget code picks up the new server update settings. + +:::note +On the Android emulator, use `10.0.2.2` instead of `localhost` to reach the host machine. Real devices need the host's LAN IP address. +::: + +## Building the server + +Voltra provides widget server handlers for the common runtime styles. Use `createWidgetUpdateHandler()` for Fetch-compatible runtimes, `createWidgetUpdateNodeHandler()` for `node:http`, and `createWidgetUpdateExpressHandler()` for Express-style handlers. All three share the same request parsing, platform validation, token validation, and response serialization. + +```tsx +import { createServer } from 'node:http' +import React from 'react' +import { createWidgetUpdateNodeHandler } from 'voltra/server' +import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android' + +const handler = createWidgetUpdateNodeHandler({ + renderAndroid: async (req) => { + // req.widgetId — the widget requesting an update + // req.platform — always "android" for Android widget requests + // req.theme — the system color scheme ("light" or "dark") + // req.token — the auth token (if credentials were set) + + const weather = await fetchWeatherData() + + const content = ( + + + + {weather.temp}° + + + {weather.condition} + + + + ) + + // Return size breakpoints for different widget sizes + return [ + { size: { width: 200, height: 100 }, content }, + { size: { width: 200, height: 200 }, content }, + { size: { width: 300, height: 200 }, content }, + ] + }, + + // Also handle iOS requests from the same endpoint + renderIos: async (req) => { + // ... + return null // or return iOS variants + }, + + validateToken: async (token) => { + return token === 'valid-token' + }, +}) + +createServer(handler).listen(3333) +``` + +The handler responds to GET requests with these query parameters: + +| Parameter | Description | +|-----------|-------------| +| `widgetId` | The widget identifier (required) | +| `platform` | The requesting platform. Must be `android` or `ios` (required). | +| `family` | The widget family/size (iOS only — absent for Android) | +| `theme` | The system color scheme (`light` or `dark`) | + +The `User-Agent` header is set to `VoltraWidget/ (Android/)`. + +## Authentication + +Widgets on Android are part of the main app binary, so the WorkManager background worker can access credential storage directly. Voltra encrypts credentials at rest using **Google Tink** (AES-256-GCM with Android Keystore-backed key management) and persists them in Jetpack DataStore. + +### Setting credentials + +Call `setWidgetServerCredentials` after the user logs in: + +```typescript +import { setWidgetServerCredentials } from 'voltra/client' + +await setWidgetServerCredentials({ + token: userAccessToken, + headers: { + 'X-App-Version': '1.0.0', + }, +}) +``` + +The `token` is required and is sent as `Authorization: Bearer ` on every server request. Any additional `headers` are also included. If your widget endpoint does not require authentication, skip `setWidgetServerCredentials()` entirely. + +### Clearing credentials + +Call `clearWidgetServerCredentials` when the user logs out: + +```typescript +import { clearWidgetServerCredentials } from 'voltra/client' + +await clearWidgetServerCredentials() +``` + +All widgets are automatically reloaded after credentials are cleared, so they revert to their default/unauthenticated state immediately. + +## Refresh button + +Server-driven widgets can display a native refresh button that lets users trigger an immediate update on demand. Enable it in your widget config: + +```json +{ + "serverUpdate": { + "url": "https://api.example.com/widgets/render", + "intervalMinutes": 60, + "refresh": true + } +} +``` + +When enabled, a small circular button (↻) appears in the top-right corner of the widget. Tapping it performs an inline HTTP fetch, generates new `RemoteViews`, and pushes the update directly to the widget—all without waiting for the next WorkManager cycle. + +:::note +The refresh callback bypasses Glance's `update()` method (which doesn't reliably trigger `provideGlance()`) and instead uses `GlanceRemoteViews.compose()` to generate `RemoteViews` that are pushed directly via `AppWidgetManager.updateAppWidget()`. +::: + +## Resize handling + +Your server should return all size variants in every response. When the user resizes a widget on the home screen, Voltra re-renders from cached data—no network request is made. The `RemoteViews(sizeMapping)` mechanism automatically picks the closest matching variant. + +## Triggering manual refreshes + +You can force-refresh server-driven widgets outside of the regular interval: + +```typescript +import { reloadWidgets } from 'voltra/client' + +// Reload specific widgets (triggers an immediate WorkManager fetch) +await reloadWidgets(['dynamic_weather']) + +// Reload all widgets +await reloadWidgets() +``` + +For server-driven widgets, this enqueues an immediate one-time WorkManager request to fetch fresh content. For local-only widgets, it re-renders from cached data. + +## Initial state + +Server-driven widgets still need content to display before the first server fetch completes. Use `initialStatePath` to provide a pre-rendered default: + +```json +{ + "id": "dynamic_weather", + "displayName": "Dynamic Weather", + "description": "Weather with live server updates", + "targetCellWidth": 2, + "targetCellHeight": 1, + "initialStatePath": "./widgets/android/weather-initial.tsx", + "serverUpdate": { + "url": "https://api.example.com/widgets/render", + "intervalMinutes": 60 + } +} +``` + +See [Widget pre-rendering](./widget-pre-rendering) for details on creating initial state files. + +:::tip +Provide a meaningful initial state (e.g. "Loading..." or placeholder content) rather than leaving it empty. The user sees this until the first server fetch succeeds. +::: + +## Cross-platform server + +A single server can handle both iOS and Android requests using `createWidgetUpdateHandler`: + +```tsx +const handler = createWidgetUpdateHandler({ + renderIos: async (req) => { + // Return WidgetVariants (systemSmall, systemMedium, etc.) + return { systemSmall: Hello } + }, + renderAndroid: async (req) => { + // Return AndroidWidgetVariants (size breakpoints) + return [{ size: { width: 200, height: 100 }, content: Hello }] + }, + validateToken: async (token) => { + // Shared token validation for both platforms + return verifyJwt(token) + }, +}) +``` + +The handler uses the required `platform` query parameter to route requests to the correct render function. + +If you're serving the endpoint from Node or Express, use `createWidgetUpdateNodeHandler()` or `createWidgetUpdateExpressHandler()` instead. + +## Architecture overview + +``` +┌─────────────────┐ setWidgetServerCredentials() ┌─────────────────────────┐ +│ React Native │ ─────────────────────────────► │ EncryptedSharedPrefs │ +│ (main app) │ └─────────────────────────┘ +└─────────────────┘ │ + │ reads token + ▼ +┌─────────────────┐ GET ?widgetId=X&platform=android&theme=Y ┌──────────────────┐ +│ WorkManager │ ─────────────────────────────► │ Your Server │ +│ (background) │ ◄───────────────────────────── │ (Voltra SSR) │ +└─────────────────┘ JSON payload └──────────────────┘ + │ + ▼ + AppWidgetManager + (RemoteViews update) + │ + ▼ + Home Screen Widget +``` + +WorkManager handles scheduling, network constraints, and retries. The background worker reads credentials from encrypted storage, makes the HTTP request, parses the response, generates `RemoteViews`, and pushes the update via `AppWidgetManager`. + +## Error handling and retries + +WorkManager automatically handles failures with exponential backoff. After 5 consecutive failed attempts, the worker gives up to avoid infinite retry loops. The next periodic run will start fresh. + +- **Network unavailable:** The request is deferred until connectivity is restored (via `NetworkType.CONNECTED` constraint). +- **Server errors (non-2xx):** The worker retries with exponential backoff, up to 3 attempts. +- **Empty response:** The worker retries with exponential backoff, up to 3 attempts. +- **Parse errors:** If the JSON is stored but parsing fails, the data is still saved so Glance can attempt to use it later. This counts as a success since the data is persisted. diff --git a/website/docs/v1/android/development/styling.md b/website/docs/v1/android/development/styling.md new file mode 100644 index 00000000..adb04052 --- /dev/null +++ b/website/docs/v1/android/development/styling.md @@ -0,0 +1,112 @@ +# Styling + +You can style Voltra components on Android using React Native-style `style` props. These properties are automatically converted to Jetpack Compose Glance modifiers. + +For Android system-aware colors, use [`AndroidDynamicColors`](./dynamic-colors) from `voltra/android` instead of snapshotting palette values in JavaScript. + +:::warning Glance Limitations +Android widgets are built using **Jetpack Compose Glance**, which has a significantly more limited styling API compared to standard Compose or SwiftUI. Many common React Native style properties are either not supported or have limited support. +::: + +## Supported Properties + +The following React Native style properties are supported on Android: + +### Layout + +- `width`, `height` - Fixed dimensions (number values in dp) or `"100%"` to fill available space. +- `flex`, `flexGrow` - Flex weight. When > 0, the component will take up a proportional amount of space in its parent container (maps to `.defaultWeight()` in Glance). +- `padding` - Uniform padding on all edges. +- `paddingTop`, `paddingBottom`, `paddingLeft`, `paddingRight` - Individual edge padding. +- `paddingHorizontal`, `paddingVertical` - Horizontal and vertical padding. +- `visibility` - Controls component visibility (`"visible"`, `"hidden"`, or `"invisible"`). + +### Visual Style + +- `backgroundColor` - Background color (hex strings, color names, or `AndroidDynamicColors.*` tokens). +- `borderRadius` - Corner radius value. **Note:** Requires Android 12+ (API 31). On older versions, this property is ignored. + +### Text + +- `fontSize` - Font size in sp. +- `fontWeight` - Supports `"normal"` and `"bold"`. +- `fontFamily` - Font family name. Built-in values: `"monospace"`, `"serif"`, `"sans-serif"`, `"cursive"`. For custom fonts, see [Custom Fonts](./custom-fonts). +- `color` - Text color (literal colors or `AndroidDynamicColors.*`). +- `textDecorationLine` - Supports `"underline"` and `"line-through"`. +- `textAlign` - Alignment of text within the component (`"left"`, `"center"`, `"right"`). +- `numberOfLines` - Limits the number of lines displayed. + +### Image Specific + +In addition to general styles, `Image` components support: + +- `resizeMode` (or `contentScale`) - `"cover"`, `"contain"`, `"stretch"`, or `"center"`. +- `alpha` - Opacity of the image (0.0 to 1.0). +- `tintColor` - Applies a color filter to the image. + +## Dynamic colors + +Android widgets can use semantic Material color roles that resolve through native `GlanceTheme.colors.*` values during rendering. + +```tsx +import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android' + +const element = ( + + + Android Widget Text + + +) +``` + +This is the preferred approach when you want widgets to follow Android's dynamic palette even when the app is not running. See [Dynamic Colors](./dynamic-colors) for the full role list and server-rendering behavior. + +## Limitations + +The following properties are **NOT supported** on Android due to Glance limitations: + +- **Margins:** `margin`, `marginTop`, etc. are currently ignored. Use `padding` on parent containers or `Spacer` components instead. +- **Borders:** `borderWidth` and `borderColor` are not yet implemented. +- **Shadows:** `shadowColor`, `shadowOffset`, `shadowOpacity`, and `shadowRadius` are not supported. +- **Positioning:** Absolute positioning (`top`, `left`, `zIndex`) is not supported. Use stack alignments and spacers. +- **Transforms:** `transform` (rotate, scale, etc.) is not supported. +- **Opacity:** The general `style.opacity` property is not supported (except for the `alpha` prop on `Image`). +- **Dimensions:** `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, and `aspectRatio` are not supported. +- **Text Effects:** `letterSpacing`, `fontVariant`, and custom `lineHeight` are not supported. + +## Example + +```tsx +import { Voltra } from 'voltra' + +const element = ( + + + Android Widget Text + + +) +``` diff --git a/website/docs/v1/android/development/testing-and-previews.md b/website/docs/v1/android/development/testing-and-previews.md new file mode 100644 index 00000000..ad3dae44 --- /dev/null +++ b/website/docs/v1/android/development/testing-and-previews.md @@ -0,0 +1,77 @@ +# Testing and Previews (Android) + +Voltra provides multiple ways to preview your Android widgets: + +1. **In-App Previews** - Preview layouts within your development app using `VoltraWidgetPreview` +2. **Widget Picker Previews** - Customize what users see in the Android widget picker when adding your widget + +This page covers in-app previews for development. For widget picker previews, see [Plugin Configuration - Widget Picker Previews](../api/plugin-configuration#widget-picker-previews). + +## VoltraWidgetPreview + +The `VoltraWidgetPreview` component renders Voltra Android JSX content at the exact dimensions of standard Android widget sizes. + +### Usage + +```tsx +import { VoltraAndroid } from 'voltra/android' +import { VoltraWidgetPreview } from 'voltra/android/client' + +export function MyWidgetPreview() { + return ( + + + + My Awesome Widget + + + This is how it looks on the home screen! + + + + ) +} +``` + +### Supported Families + +Android widgets use responsive sizing. Voltra provides several standard families based on typical grid dimensions: + +| Family | DP Dimensions | Typical Grid Size | +| :--- | :--- | :--- | +| `small` | 150 x 100 | 2x1 | +| `mediumSquare` | 200 x 200 | 2x2 | +| `mediumWide` | 250 x 150 | 3x2 | +| `mediumTall` | 150 x 250 | 2x3 | +| `large` | 300 x 200 | 4x2 | +| `extraLarge` | 350 x 300 | 4x4 | + +## VoltraView (Android) + +If you need more control or want to test custom dimensions, you can use the low-level `VoltraView` component. + +```tsx +import { VoltraView } from 'voltra/android/client' + + + + Custom Preview + + +``` + +## Accuracy + +The Android preview components use the **actual native Glance renderers** under the hood. When you provide JSX to `VoltraWidgetPreview`, it is converted to a native `RemoteViews` object and rendered using the same logic that Android uses on the home screen. + +This ensures that: +- Layout constraints are respected. +- Styling (colors, fonts, spacing) is accurate. +- Component mapping is identical to the production widget. + +:::info +While layout and styling are accurate, some home-screen specific behaviors (like actual widget resizing by the user) are not simulated by the preview component. +::: diff --git a/website/docs/v1/android/development/widget-pre-rendering.md b/website/docs/v1/android/development/widget-pre-rendering.md new file mode 100644 index 00000000..f9b72617 --- /dev/null +++ b/website/docs/v1/android/development/widget-pre-rendering.md @@ -0,0 +1,58 @@ +# Widget Pre-rendering (Android) + +Widget pre-rendering allows you to provide a meaningful initial state for your Android widgets before they are updated by the app for the first time. + +## Configuration + +Add `initialStatePath` to your widget configuration in the `android` section of the Voltra plugin in `app.json`: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "android": { + "widgets": [ + { + "id": "weather", + "name": "Weather Widget", + "initialStatePath": "./widgets/weather-android-initial.tsx" + } + ] + } + } + ] + ] + } +} +``` + +## Implementation + +Create a file at the specified `initialStatePath` that exports a default Voltra component (or a React element). For Android, this should use `VoltraAndroid` primitives. + +```tsx +import { VoltraAndroid } from 'voltra' + +const InitialWeatherWidget = ( + + + Loading weather... + + +) + +export default InitialWeatherWidget +``` + +## Build Process + +During the build process (`npx expo prebuild`), Voltra executes these initial state files in a Node.js environment to generate the static layouts that will be displayed when the widget is first added to the home screen. + +## Limitations + +- **Environment**: The code runs in Node.js during build time, not on the device. +- **Imports**: Ensure you only import from `voltra` and avoid any browser or React Native specific APIs that aren't supported in Node.js. +- **Static Content**: The initial state should represent a "loading" or "offline" state, as it won't have access to dynamic runtime data until the app runs. diff --git a/website/docs/v1/android/introduction.md b/website/docs/v1/android/introduction.md new file mode 100644 index 00000000..7c1b496c --- /dev/null +++ b/website/docs/v1/android/introduction.md @@ -0,0 +1,71 @@ +# Android Introduction + +:::warning Experimental Support +Android support is **experimental**. Although it should work just fine, the API may change. Stay vigilant. +::: + +Voltra brings the power of JSX-based UI to Android Home Screen widgets. Using Jetpack Compose Glance under the hood, Voltra allows you to define Android widgets using a set of primitives that map directly to Glance components. + +## Widgets on Android + +Android widgets have different layout and styling rules compared to iOS Live Activities. While iOS uses SwiftUI-based primitives (VStack, HStack, etc.), Android uses Jetpack Compose Glance primitives (Column, Row, Box). + +Voltra abstracts these differences where possible, but provides platform-specific namespaces to ensure your UI looks and behaves correctly on each platform. + +Voltra also exposes Android-specific semantic dynamic colors through `AndroidDynamicColors`, which lets widgets follow the current Material palette without requiring a JavaScript re-render. See [Dynamic Colors](./development/dynamic-colors). + +Voltra also supports Android ongoing notifications for app-driven, persistent status updates. See [Managing Android Ongoing Notifications](./development/managing-ongoing-notifications). + +### Simple Android Widget + +```tsx +import { VoltraAndroid } from 'voltra' + +const MyWidget = () => ( + + + Android Widget + + + Powered by Voltra & Glance + + +) +``` + +## Key Differences + +- **Primitives:** Use `VoltraAndroid.Column`, `VoltraAndroid.Row`, and `VoltraAndroid.Box` instead of stacks. +- **Alignment:** Android uses specific alignment props like `verticalAlignment` and `horizontalAlignment`. +- **Sizing:** Use `"100%"` for full size or `"auto"` for wrapping content. + +## Testing and Previews + +You can preview your Android widgets directly in your app using the `VoltraWidgetPreview` component. This allows for fast iteration without needing to constantly check the home screen. + +Learn more in the [Testing and Previews guide](./development/testing-and-previews). + +## Next Steps + +Check out the [Setup guide](./setup) to set up Voltra for Android. + +For notification-based experiences, see [Managing Android Ongoing Notifications](./development/managing-ongoing-notifications). diff --git a/website/docs/v1/android/setup.mdx b/website/docs/v1/android/setup.mdx new file mode 100644 index 00000000..02904037 --- /dev/null +++ b/website/docs/v1/android/setup.mdx @@ -0,0 +1,53 @@ +import { PackageManagerTabs } from '@rspress/core/theme' + +# Android Setup + +Once you have [installed Voltra](../getting-started/installation), you need to configure the Expo plugin for Android. + +## 1. Configure the Expo Plugin + +Add the Voltra plugin to your `app.json` or `app.config.js`: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "android": { + "widgets": [ + { + "id": "my_widget", + "name": "My First Widget", + "description": "A simple Voltra widget" + } + ] + } + } + ] + ] + } +} +``` + +## 2. Prebuild for Android + +Update your native Android project: + + + +## 3. Run the app + +```bash +npx expo run:android +``` + +Your widget should now be available in the Android widget picker! diff --git a/website/docs/v1/getting-started/_meta.json b/website/docs/v1/getting-started/_meta.json new file mode 100644 index 00000000..d4204e28 --- /dev/null +++ b/website/docs/v1/getting-started/_meta.json @@ -0,0 +1,17 @@ +[ + { + "type": "file", + "name": "introduction", + "label": "Introduction" + }, + { + "type": "file", + "name": "installation", + "label": "Installation" + }, + { + "type": "file", + "name": "prior-art", + "label": "Prior Art" + } +] diff --git a/website/docs/v1/getting-started/installation.mdx b/website/docs/v1/getting-started/installation.mdx new file mode 100644 index 00000000..31f89e5e --- /dev/null +++ b/website/docs/v1/getting-started/installation.mdx @@ -0,0 +1,18 @@ +import { PackageManagerTabs } from '@rspress/core/theme' + +# Installation + +To use Voltra in your project, you only need to install one package. + +## 1. Install the package + +Add Voltra to your Expo project: + + + +## 2. Next Steps + +After installing the package, you need to configure the Voltra Expo plugin for each platform you want to support. + +- [Configure iOS Setup](../ios/setup) +- [Configure Android Setup](../android/setup) diff --git a/website/docs/v1/getting-started/introduction.md b/website/docs/v1/getting-started/introduction.md new file mode 100644 index 00000000..c9fe60f1 --- /dev/null +++ b/website/docs/v1/getting-started/introduction.md @@ -0,0 +1,65 @@ +# Introduction + +Voltra is a library that brings new "platforms" to React Native. Up until now, creating features like iOS Live Activities, Dynamic Island layouts, or Android Home Screen Widgets required writing native code in Swift or Kotlin. + +Voltra changes this by providing a JavaScript-based API and JSX components that get automatically converted to native primitives (SwiftUI on iOS, Jetpack Compose Glance on Android). + +## Why Voltra? + +- **React Native Everywhere:** Extend your React Native app with native platform features using the same JSX syntax you already know. +- **No Native Code Required:** Build complex widget layouts and live activities without touching Xcode or Android Studio for UI code. +- **Unified Components:** Use a shared set of components that render idiomatically on both iOS and Android. +- **Real-time Updates:** Stream updates to your activities and widgets via push notifications (APNS/FCM) from any JavaScript runtime. + +## How it works + +Voltra works by serializing your JSX components into a lightweight JSON format that the native platform extensions can interpret. This enables features like hot reloading during development and server-side rendering for push updates. + +Here's how simple it is to create a live activity: + +```tsx +import { startLiveActivity } from 'voltra/client' +import { Voltra } from 'voltra' + +const activityUI = ( + + + Driver en route + Building A · Lobby pickup + + Contact driver + + +) + +// Start the live activity +await startLiveActivity({ + lockScreen: activityUI, +}) +``` + +If you prefer using the hook API (`useLiveActivity`), you'll get live reloads for live activities, with changes appearing in milliseconds without manual restarts. + +## Server-side updates via push notifications + +Voltra also supports server-side updates through push notifications. You can use Voltra's server-side rendering to convert JSX into JSON payloads that you send to devices via Apple's Push Notification Service (APNS) or Firebase Cloud Messaging (FCM). This enables real-time updates without keeping your app running. + +The same components you use in your app work on the server: + +```tsx +import { renderLiveActivityToString } from 'voltra/server' +import { Voltra } from 'voltra' + +// Render JSX to JSON payload on your server +const payload = renderLiveActivityToString({ + lockScreen: ( + + + Driver arrived + Ready for pickup + + ), +}) +``` + +Ready to get started? Head over to the [Installation](./installation) guide, or explore platform-specific guides for [iOS](/ios/introduction) and [Android](/android/introduction). diff --git a/website/docs/getting-started/prior-art.md b/website/docs/v1/getting-started/prior-art.md similarity index 100% rename from website/docs/getting-started/prior-art.md rename to website/docs/v1/getting-started/prior-art.md diff --git a/website/docs/index.md b/website/docs/v1/index.md similarity index 100% rename from website/docs/index.md rename to website/docs/v1/index.md diff --git a/website/docs/ios/_meta.json b/website/docs/v1/ios/_meta.json similarity index 100% rename from website/docs/ios/_meta.json rename to website/docs/v1/ios/_meta.json diff --git a/website/docs/ios/api/_meta.json b/website/docs/v1/ios/api/_meta.json similarity index 100% rename from website/docs/ios/api/_meta.json rename to website/docs/v1/ios/api/_meta.json diff --git a/website/docs/v1/ios/api/configuration.md b/website/docs/v1/ios/api/configuration.md new file mode 100644 index 00000000..dc3d9b0f --- /dev/null +++ b/website/docs/v1/ios/api/configuration.md @@ -0,0 +1,120 @@ +# Configuration + +Voltra provides several configuration options to control Live Activity behavior, lifecycle, and appearance. These options can be used when starting, updating, or stopping Live Activities. + +For Expo plugin configuration options (like `groupIdentifier`, `enablePushNotifications`, `deploymentTarget`, and `widgets`), see the [Plugin Configuration](./plugin-configuration) documentation. + +## Dismissal Policy + +Voltra supports configuring how Live Activities behave after they end. You can control the dismissal timing using the `dismissalPolicy` option: + +### Dismissal Policy Options + +- **`'immediate'`** (default): The Live Activity is dismissed immediately when it ends +- **`{ after: number }`**: The Live Activity remains visible for the specified number of seconds after ending, then automatically dismisses + +### Usage Examples + +**Immediate dismissal (default behavior):** + +```typescript +import { startLiveActivity } from 'voltra/client' + +await startLiveActivity(variants, { + dismissalPolicy: 'immediate', // or omit for default +}) +``` + +**Delayed dismissal (keep visible for 30 seconds after ending):** + +```typescript +await startLiveActivity(variants, { + dismissalPolicy: { after: 30 }, +}) +``` + +**Update dismissal policy for active Live Activities:** + +```typescript +import { updateLiveActivity } from 'voltra/client' + +await updateLiveActivity(activityId, variants, { + dismissalPolicy: { after: 60 }, +}) +``` + +**Set dismissal policy when ending a Live Activity:** + +```typescript +import { stopLiveActivity } from 'voltra/client' + +await stopLiveActivity(activityId, { + dismissalPolicy: { after: 10 }, +}) +``` + +The dismissal policy applies to both programmatic ending (`stopLiveActivity`) and natural ending (when timers reach their end time). This gives you fine-grained control over the user experience when Live Activities conclude. + +## Additional Configuration Options + +Voltra provides additional configuration options to control Live Activity behavior and appearance. + +### Stale Date + +The `staleDate` option allows you to specify when a Live Activity should be considered stale and automatically dismissed by the system. + +```typescript +import { startLiveActivity } from 'voltra/client' + +// Dismiss the Live Activity after 1 hour +await startLiveActivity(variants, { + staleDate: Date.now() + 60 * 60 * 1000, // 1 hour from now +}) +``` + +**Note:** If you provide a `staleDate` in the past, it will be ignored and the Live Activity will use default behavior. + +### Relevance Score + +The `relevanceScore` option helps iOS prioritize which Live Activities to display when space is limited. Higher scores (closer to 1.0) indicate more important activities. + +```typescript +import { startLiveActivity } from 'voltra/client' + +// High priority Live Activity (e.g., active delivery) +await startLiveActivity(variants, { + relevanceScore: 0.8, +}) + +// Low priority Live Activity (e.g., background task) +await startLiveActivity(variants, { + relevanceScore: 0.2, +}) +``` + +**Valid range:** 0.0 to 1.0 (default: 0.0) + +### Channel ID (broadcast push, iOS 18+) + +The `channelId` option subscribes the Live Activity to a broadcast channel for server-side updates. When provided, the activity receives updates via broadcast push notifications instead of individual device tokens—one server notification updates all activities on that channel. Requires `enablePushNotifications: true` and the Broadcast Capability enabled in your Apple Developer account. + +```typescript +import { startLiveActivity } from 'voltra/client' + +await startLiveActivity(variants, { + activityName: 'match-123', + channelId: 'CTrNsYq/Ee8AALLzHQaVlA==', // From APNs channel management +}) +``` + +For full broadcast setup, see [Server-side updates - Broadcast push notifications](../development/server-side-updates.md#broadcast-push-notifications-ios-18). + +These options can be used together with dismissal policy and other configuration options: + +```typescript +await startLiveActivity(variants, { + dismissalPolicy: { after: 30 }, + staleDate: Date.now() + 2 * 60 * 60 * 1000, // 2 hours + relevanceScore: 0.7, +}) +``` diff --git a/website/docs/v1/ios/api/plugin-configuration.md b/website/docs/v1/ios/api/plugin-configuration.md new file mode 100644 index 00000000..b7558041 --- /dev/null +++ b/website/docs/v1/ios/api/plugin-configuration.md @@ -0,0 +1,125 @@ +# Plugin configuration + +The Voltra Expo config plugin accepts several configuration options in your `app.json` or `app.config.js`: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "groupIdentifier": "group.your.bundle.identifier", + "enablePushNotifications": true, + "deploymentTarget": "18.0", + "targetName": "MyAppLiveActivity", + "widgets": [ + { + "id": "weather", + "displayName": "Weather Widget", + "description": "Shows current weather conditions", + "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], + "initialStatePath": "./widgets/weather-initial.tsx" + } + ] + } + ] + ] + } +} +``` + +## Configuration options + +### `groupIdentifier` (optional) + +App Group identifier for sharing data between your app and the widget extension. Required if you want to: + +- Forward component events (like button taps) from Live Activities to your JavaScript code +- Share images between your app and the extension +- Use image preloading features + +**Format:** Must start with `group.` (e.g., `group.your.bundle.identifier`) + +### `enablePushNotifications` (optional) + +Enable server-side updates for Live Activities via Apple Push Notification Service (APNS). When enabled, you can update Live Activities even when your app is in the background or terminated. + +**Type:** `boolean` +**Default:** `false` + +### `deploymentTarget` (optional) + +iOS deployment target version for the widget extension. If not provided, defaults to `17.0`. This allows the widget extension to have its own deployment target independent of the main app. + +**Type:** `string` +**Default:** `"17.0"` +**Example:** `"18.0"` + +**Note:** Code signing settings (development team, provisioning profiles) are automatically synchronized from the main app target, but the deployment target can be set independently. + +### `targetName` (optional) + +Custom target name for the widget extension. If not provided, defaults to `{AppName}LiveActivity` where `AppName` is your app's sanitized name. + +This is useful when: +- Migrating from other Live Activity solutions (e.g., `@bacons/apple-targets`) +- Matching existing provisioning profiles or credentials +- Using a specific naming convention for your organization + +**Type:** `string` +**Default:** `"{AppName}LiveActivity"` +**Example:** `"widget"`, `"MyAppLiveActivity"` + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "groupIdentifier": "group.your.bundle.identifier", + "targetName": "widget" + } + ] + ] + } +} +``` + +### `widgets` (optional) + +Array of widget configurations for Home Screen widgets. Each widget will be available in the iOS widget gallery. + +**Widget Configuration Properties:** + +- `id`: Unique identifier for the widget (alphanumeric with underscores only) +- `displayName`: Name shown in the widget gallery +- `description`: Description shown in the widget gallery +- `supportedFamilies`: Array of supported widget sizes (`systemSmall`, `systemMedium`, `systemLarge`) +- `initialStatePath`: (optional) Path to a file that exports initial widget state (see [Widget Pre-rendering](../development/widget-pre-rendering)) +- `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](../development/server-driven-widgets) for full details. + - `url`: The Voltra SSR endpoint URL + - `intervalMinutes`: Update interval in minutes (default: `15`) + - `refresh`: Show a native refresh button (default: `false`, requires iOS 17+) + +**Example:** + +```json +{ + "widgets": [ + { + "id": "weather", + "displayName": "Weather Widget", + "description": "Current weather conditions", + "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], + "initialStatePath": "./widgets/weather-initial.tsx", + "serverUpdate": { + "url": "https://api.example.com/widgets/render", + "intervalMinutes": 30, + "refresh": true + } + } + ] +} +``` diff --git a/website/docs/ios/charts.md b/website/docs/v1/ios/charts.md similarity index 100% rename from website/docs/ios/charts.md rename to website/docs/v1/ios/charts.md diff --git a/website/docs/ios/components/_meta.json b/website/docs/v1/ios/components/_meta.json similarity index 100% rename from website/docs/ios/components/_meta.json rename to website/docs/v1/ios/components/_meta.json diff --git a/website/docs/v1/ios/components/interactive.md b/website/docs/v1/ios/components/interactive.md new file mode 100644 index 00000000..d4338b1f --- /dev/null +++ b/website/docs/v1/ios/components/interactive.md @@ -0,0 +1,177 @@ +# Interactive Controls (iOS) + +User interface controls that respond to user interaction in Live Activities. + +--- + +## Button + +An interactive button component that triggers in-app events via interaction intents. + +**Parameters:** + +- `buttonStyle` (string, optional): Visual style of the button: + - `"automatic"` - System-determined style + - `"bordered"` - Bordered style + - `"borderedProminent"` - Bordered with prominent fill + - `"plain"` - Plain style without border + - `"borderless"` - Borderless style + +**Apple Documentation:** [Button](https://developer.apple.com/documentation/swiftui/button) + +**Availability:** iOS 17.0+ (interaction intents) + +### Usage + +Buttons fire interaction events that you can handle in your app: + +```tsx + + Play Music + +``` + +Handle the event: + +```typescript +import { addVoltraListener } from 'voltra/client' + +const subscription = addVoltraListener('interaction', (event) => { + if (event.identifier === 'play-button') { + // Handle play action + } +}) +``` + +### Examples + +**Styled button:** + +```tsx + + Save Changes + +``` + +**Button with icon:** + +```tsx + + + + Delete + + +``` + +**Compact button:** + +```tsx + + + +``` + +--- + +## Link + +A navigable link component that opens a URL when tapped. Uses SwiftUI's native Link for semantic navigation. + +**Parameters:** + +- `destination` (string, required): URL to navigate to when tapped. Supports both absolute URLs and relative paths. + +**Apple Documentation:** [Link](https://developer.apple.com/documentation/swiftui/link) + +**Availability:** iOS 14.0+ + +### URL Normalization + +Link automatically normalizes URLs using your app's URL scheme: + +- Absolute URLs: Used as-is (`"myapp://orders/123"`, `"https://example.com"`) +- Relative with `/`: `"/settings"` → `"myapp://settings"` +- Relative without `/`: `"help"` → `"myapp://help"` + +### Examples + +**Link with absolute URL:** + +```tsx + + + + + Order #123 + Tap to view details + + + +``` + +**Link with relative path:** + +```tsx + + + + Open Settings + + +``` + +**External link:** + +```tsx + + + + Visit Support Site + + +``` + +### When to use Link vs Button + +| Feature | Link | Button | +|---------|------|--------| +| **Use Case** | Navigation to URLs | In-app actions/events | +| **Visual** | Unstyled (custom via children) | Button styling (bordered, prominent, etc.) | +| **iOS Version** | 14.0+ | 17.0+ | +| **Tap Behavior** | Opens URL | Fires interaction event | +| **Mechanism** | SwiftUI Link | AppIntents (VoltraInteractionIntent) | + +**Recommendation:** Use `Link` for navigation (e.g., list items, cards that open URLs). Use `Button` for actions that your app needs to handle (e.g., play/pause, save, delete). + +--- + +## Toggle + +Toggles a boolean state via an intent. Fires an interaction event when changed. + +**Parameters:** + +- `defaultValue` (boolean, optional): Initial toggle state (default: `false`) + +**Apple Documentation:** [Toggle](https://developer.apple.com/documentation/swiftui/toggle) + +**Availability:** iOS 17.0+ + +**Example:** + +```tsx + +``` + +Handle toggle events: + +```typescript +import { addVoltraListener } from 'voltra/client' + +const subscription = addVoltraListener('interaction', (event) => { + if (event.identifier === 'notifications-toggle') { + // Handle toggle state change + } +}) +``` diff --git a/website/docs/ios/components/layout.md b/website/docs/v1/ios/components/layout.md similarity index 100% rename from website/docs/ios/components/layout.md rename to website/docs/v1/ios/components/layout.md diff --git a/website/docs/v1/ios/components/overview.md b/website/docs/v1/ios/components/overview.md new file mode 100644 index 00000000..7a0c9541 --- /dev/null +++ b/website/docs/v1/ios/components/overview.md @@ -0,0 +1,51 @@ +# Components Overview (iOS) + +Voltra provides SwiftUI primitives with JSX bindings, allowing developers to create rich, interactive Live Activities using React/JSX syntax. These components connect web development workflows with native iOS Live Activity rendering. + +## Getting Started + +All Voltra components are available through the main `Voltra` namespace: + +```tsx +import Voltra from 'voltra' + +const MyComponent = () => { + // Use any component + return ( + + Hello Live Activity! + + Tap me + + + ) +} +``` + +## Component Categories + +Voltra organizes its components into categories: + +### Layout & Containers + +Components that arrange other elements or provide structural grouping. These include stacks (VStack, HStack, ZStack), spacers, and container components like GroupBox and GlassContainer. + +[See all layout & container components →](./layout) + +### Visual Elements & Typography + +Static or decorative elements used to display content. This category includes Text, Label, Image, Symbol, and visual effects like LinearGradient, Mask, and Divider. + +[See all visual elements & typography components →](./visual) + +### Data Visualization & Status + +Components for displaying data and status information. This includes progress indicators (LinearProgressView, CircularProgressView), gauges, and timers for showing dynamic information in Live Activities. + +[See all data visualization & status components →](./status) + +### Interactive Controls & Navigation + +User interface controls that respond to user interaction and navigation. This category includes Button and Toggle components for interactive Live Activities, plus Link for semantic URL navigation. + +[See all interactive control & navigation components →](./interactive) diff --git a/website/docs/ios/components/status.md b/website/docs/v1/ios/components/status.md similarity index 100% rename from website/docs/ios/components/status.md rename to website/docs/v1/ios/components/status.md diff --git a/website/docs/ios/components/visual.md b/website/docs/v1/ios/components/visual.md similarity index 100% rename from website/docs/ios/components/visual.md rename to website/docs/v1/ios/components/visual.md diff --git a/website/docs/ios/development/_meta.json b/website/docs/v1/ios/development/_meta.json similarity index 100% rename from website/docs/ios/development/_meta.json rename to website/docs/v1/ios/development/_meta.json diff --git a/website/docs/v1/ios/development/developing-live-activities.md b/website/docs/v1/ios/development/developing-live-activities.md new file mode 100644 index 00000000..9319d306 --- /dev/null +++ b/website/docs/v1/ios/development/developing-live-activities.md @@ -0,0 +1,229 @@ +# Developing Live Activities + +Voltra provides APIs that make building and testing Live Activities easier during development. + +## Supported variants + +Live Activities in iOS can appear in different contexts, and Voltra supports defining UI variants for each of these contexts. For detailed information about Live Activity design guidelines, see the [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/live-activities). + +### Lock Screen + +The `lockScreen` variant defines how your Live Activity appears on the lock screen. It can be either a ReactNode directly, or an object with content and optional styling: + +```typescript +const variants = { + lockScreen: ( + + Your content here + + ), +} +``` + +To customize the system Lock Screen chrome, pass an object with `content` and `activityBackgroundTint`: + +```typescript +const variants = { + lockScreen: { + activityBackgroundTint: '#101828', + content: ( + + Your content here + + ), + }, +} +``` + +`activityBackgroundTint` is applied via SwiftUI's `activityBackgroundTint(...)` modifier on iOS. Voltra currently accepts the same color formats handled by the native iOS parser: + +- Hex colors such as `#RGB`, `#RGBA`, `#RRGGBB`, and `#RRGGBBAA` +- `rgb(...)` and `rgba(...)` +- `hsl(...)` and `hsla(...)` +- Named colors: `red`, `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `white`, `gray`, `black`, `clear`, `transparent`, `primary`, `secondary` + +Use `clear` or `transparent` to make the Live Activity background transparent. + +If the string cannot be parsed as one of those formats, iOS ignores the tint. + +### Dynamic Island + +The `island` variant defines how your Live Activity appears in the Dynamic Island (available on iPhone 14 Pro and later). The Dynamic Island has three display states: + +- **Minimal**: A compact pill-shaped view that appears when the activity is in the background +- **Compact**: A slightly larger view with leading and trailing regions +- **Expanded**: A full-width view with center, leading, trailing, and bottom regions + +```typescript +const variants = { + island: { + keylineTint: '#10B981', // Optional tint color for the Dynamic Island keyline + minimal: , + compact: { + leading: Order, + trailing: Confirmed, + }, + expanded: { + center: Order Confirmed, + leading: , + trailing: ETA: 15 min, + bottom: Your order is being prepared, + }, + }, +} +``` + +`keylineTint` uses the same iOS color parser and accepted formats as `activityBackgroundTint`. + +### Supplemental Activity Families (iOS 18+, watchOS 11+) + +The `supplementalActivityFamilies` variant defines how your Live Activity appears on Apple Watch Smart Stack and CarPlay displays. This variant is optional and works seamlessly with your existing lock screen and Dynamic Island variants. + +```typescript +const variants = { + lockScreen: ( + + {/* iPhone lock screen content */} + + ), + island: { + /* Dynamic Island variants for iPhone */ + }, + supplementalActivityFamilies: { + small: ( + + 12 min + ETA + + ), + }, +} +``` + +If `supplementalActivityFamilies.small` is not provided, Voltra will automatically construct it from your Dynamic Island `compact` variant by combining the leading and trailing content in an HStack. + +## Limitations + +### Animations and Live Updates + +There are specific constraints on how content can animate or update: + +- **Continuous Animations**: Custom continuous animations, such as rotating icons or elements moving along a path, are not supported. +- **Smooth Updates**: Per-second "live" updates are only supported by specific components designed for this purpose: + - `Timer`: For countdowns and stopwatches. + - `LinearProgressView`: When used with `timerInterval`. +- **Styling Trade-offs**: To enable smooth, system-driven animations (like a progress bar filling up in real-time), certain components may ignore custom styling properties (e.g., custom heights or thumb components) and fallback to standard system appearances. + +All other components only update their visual state when a new activity state is pushed from your application. + +## useLiveActivity + +For React development, Voltra provides the `useLiveActivity` hook for integration with the component lifecycle and automatic updates during development. + +:::warning +Unfortunately, iOS suspends background apps after approximately 30 seconds. This means that if you navigate away from your app (for example, to check the Dynamic Island or lock screen), live reload and auto-update functionality will be paused. +::: + +```typescript +import { useLiveActivity } from 'voltra/client' +import { Voltra } from 'voltra' + +function OrderLiveActivity({ orderId, status }) { + const variants = { + lockScreen: ( + + + {status === 'confirmed' ? 'Order Confirmed' : 'Order Ready'} + + + {status === 'confirmed' ? 'Your order is being prepared' : 'Your order is ready for pickup'} + + {status === 'ready' && ( + + I'm Here + + )} + + ), + } + + const { start, update, end, isActive } = useLiveActivity(variants, { + activityName: `order-${orderId}`, + autoStart: true, // Automatically start when component mounts + autoUpdate: true, // Automatically update when variants change + deepLinkUrl: `myapp://order/${orderId}`, + }) + + // Manual control if needed + const handleCancelOrder = async () => { + await end() + } + + return ( + + Live Activity: {isActive ? 'Active' : 'Inactive'} +