diff --git a/example/app.json b/example/app.json
index 55bae56..e563ef7 100644
--- a/example/app.json
+++ b/example/app.json
@@ -24,6 +24,9 @@
{
"groupIdentifier": "group.callstackincubator.voltraexample",
"enablePushNotifications": true,
+ "liveActivity": {
+ "supplementalActivityFamilies": ["small"]
+ },
"widgets": [
{
"id": "weather",
diff --git a/example/components/live-activities/WatchLiveActivityUI.tsx b/example/components/live-activities/WatchLiveActivityUI.tsx
new file mode 100644
index 0000000..fc7c336
--- /dev/null
+++ b/example/components/live-activities/WatchLiveActivityUI.tsx
@@ -0,0 +1,59 @@
+import React from 'react'
+import { Voltra } from 'voltra'
+
+export function WatchLiveActivityLockScreen() {
+ return (
+
+
+
+
+
+ Workout Active
+
+
+ Running ยท 3.2 km
+
+
+
+
+
+
+ 25:42
+ Duration
+
+
+ 142
+ BPM
+
+
+ 312
+ Calories
+
+
+
+ )
+}
+
+export function WatchLiveActivitySmall() {
+ return (
+
+ 25:42
+ 142 BPM
+
+ )
+}
diff --git a/example/screens/live-activities/LiveActivitiesScreen.tsx b/example/screens/live-activities/LiveActivitiesScreen.tsx
index cca3f62..2fb0c2b 100644
--- a/example/screens/live-activities/LiveActivitiesScreen.tsx
+++ b/example/screens/live-activities/LiveActivitiesScreen.tsx
@@ -12,11 +12,12 @@ import CompassLiveActivity from '~/screens/live-activities/CompassLiveActivity'
import FlightLiveActivity from '~/screens/live-activities/FlightLiveActivity'
import LiquidGlassLiveActivity from '~/screens/live-activities/LiquidGlassLiveActivity'
import MusicPlayerLiveActivity from '~/screens/live-activities/MusicPlayerLiveActivity'
+import WatchLiveActivity from '~/screens/live-activities/WatchLiveActivity'
import WorkoutLiveActivity from '~/screens/live-activities/WorkoutLiveActivity'
import { LiveActivityExampleComponentRef } from './types'
-type ActivityKey = 'basic' | 'stylesheet' | 'glass' | 'flight' | 'workout' | 'compass'
+type ActivityKey = 'basic' | 'stylesheet' | 'glass' | 'flight' | 'workout' | 'compass' | 'watch'
const ACTIVITY_METADATA: Record = {
basic: {
@@ -43,9 +44,13 @@ const ACTIVITY_METADATA: Record(null)
@@ -65,6 +71,7 @@ export default function LiveActivitiesScreen() {
const flightRef = useRef(null)
const workoutRef = useRef(null)
const compassRef = useRef(null)
+ const watchRef = useRef(null)
const activityRefs = useMemo(
() => ({
@@ -74,6 +81,7 @@ export default function LiveActivitiesScreen() {
flight: flightRef,
workout: workoutRef,
compass: compassRef,
+ watch: watchRef,
}),
[]
)
@@ -109,6 +117,10 @@ export default function LiveActivitiesScreen() {
(isActive: boolean) => handleStatusChange('compass', isActive),
[handleStatusChange]
)
+ const handleWatchStatusChange = useCallback(
+ (isActive: boolean) => handleStatusChange('watch', isActive),
+ [handleStatusChange]
+ )
const handleStart = async (key: ActivityKey) => {
await activityRefs[key].current?.start?.().catch(console.error)
@@ -187,6 +199,7 @@ export default function LiveActivitiesScreen() {
+
)
diff --git a/example/screens/live-activities/WatchLiveActivity.tsx b/example/screens/live-activities/WatchLiveActivity.tsx
new file mode 100644
index 0000000..9b4a57d
--- /dev/null
+++ b/example/screens/live-activities/WatchLiveActivity.tsx
@@ -0,0 +1,48 @@
+import React, { forwardRef, useEffect, useImperativeHandle } from 'react'
+import { useLiveActivity } from 'voltra/client'
+
+import {
+ WatchLiveActivityLockScreen,
+ WatchLiveActivitySmall,
+} from '../../components/live-activities/WatchLiveActivityUI'
+import { LiveActivityExampleComponent } from './types'
+
+const WatchLiveActivity: LiveActivityExampleComponent = forwardRef(
+ ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => {
+ const { start, update, end, isActive } = useLiveActivity(
+ {
+ lockScreen: {
+ content: ,
+ },
+ supplementalActivityFamilies: {
+ small: ,
+ },
+ island: {
+ keylineTint: '#10B981',
+ },
+ },
+ {
+ activityName: 'watch-demo',
+ autoUpdate,
+ autoStart,
+ deepLinkUrl: '/voltraui/watch-demo',
+ }
+ )
+
+ useEffect(() => {
+ onIsActiveChange?.(isActive)
+ }, [isActive, onIsActiveChange])
+
+ useImperativeHandle(ref, () => ({
+ start,
+ update,
+ end,
+ }))
+
+ return null
+ }
+)
+
+WatchLiveActivity.displayName = 'WatchLiveActivity'
+
+export default WatchLiveActivity
diff --git a/ios/shared/VoltraRegion.swift b/ios/shared/VoltraRegion.swift
index f27d914..d799741 100644
--- a/ios/shared/VoltraRegion.swift
+++ b/ios/shared/VoltraRegion.swift
@@ -9,6 +9,7 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable {
case islandCompactLeading
case islandCompactTrailing
case islandMinimal
+ case supplementalActivityFamiliesSmall
/// The JSON key for this region in the payload
public var jsonKey: String {
@@ -29,6 +30,8 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable {
return "isl_cmp_t"
case .islandMinimal:
return "isl_min"
+ case .supplementalActivityFamiliesSmall:
+ return "saf_sm"
}
}
}
diff --git a/ios/target/VoltraWidget.swift b/ios/target/VoltraWidget.swift
index dda283f..9cbcf9b 100644
--- a/ios/target/VoltraWidget.swift
+++ b/ios/target/VoltraWidget.swift
@@ -14,6 +14,37 @@ public struct VoltraWidget: Widget {
}
public var body: some WidgetConfiguration {
+ if #available(iOS 18.0, *) {
+ return withAdaptiveViewConfig()
+ } else {
+ return defaultViewConfig()
+ }
+ }
+
+ // MARK: - iOS 18+ Configuration (with adaptive view for supplemental activity families)
+
+ @available(iOS 18.0, *)
+ private func withAdaptiveViewConfig() -> some WidgetConfiguration {
+ ActivityConfiguration(for: VoltraAttributes.self) { context in
+ VoltraAdaptiveLockScreenView(
+ context: context,
+ rootNodeProvider: rootNode
+ )
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ .voltraIfLet(context.state.activityBackgroundTint) { view, tint in
+ let color = JSColorParser.parse(tint)
+ view.activityBackgroundTint(color)
+ }
+ } dynamicIsland: { context in
+ dynamicIslandContent(context: context)
+ }
+ // NOTE: .supplementalActivityFamilies() is applied by VoltraWidgetWithSupplementalActivityFamilies
+ // wrapper when configured via plugin (see VoltraWidgetBundle.swift)
+ }
+
+ // MARK: - Default Configuration (iOS 16.2 - 17.x)
+
+ private func defaultViewConfig() -> some WidgetConfiguration {
ActivityConfiguration(for: VoltraAttributes.self) { context in
Voltra(root: rootNode(for: .lockScreen, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
@@ -22,42 +53,77 @@ public struct VoltraWidget: Widget {
view.activityBackgroundTint(color)
}
} dynamicIsland: { context in
- let dynamicIsland = DynamicIsland {
- DynamicIslandExpandedRegion(.leading) {
- Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- DynamicIslandExpandedRegion(.trailing) {
- Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- DynamicIslandExpandedRegion(.center) {
- Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- DynamicIslandExpandedRegion(.bottom) {
- Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- } compactLeading: {
- Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID)
+ dynamicIslandContent(context: context)
+ }
+ }
+
+ // MARK: - Dynamic Island (shared between iOS versions)
+
+ private func dynamicIslandContent(context: ActivityViewContext) -> DynamicIsland {
+ let dynamicIsland = DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- } compactTrailing: {
- Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- } minimal: {
- Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID)
+ }
+ DynamicIslandExpandedRegion(.center) {
+ Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
}
-
- // Apply keylineTint if specified
- if let keylineTint = context.state.keylineTint,
- let color = JSColorParser.parse(keylineTint)
- {
- return dynamicIsland.keylineTint(color)
- } else {
- return dynamicIsland
+ DynamicIslandExpandedRegion(.bottom) {
+ Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
}
+ } compactLeading: {
+ Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ } compactTrailing: {
+ Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ } minimal: {
+ Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ }
+
+ // Apply keylineTint if specified
+ if let keylineTint = context.state.keylineTint,
+ let color = JSColorParser.parse(keylineTint)
+ {
+ return dynamicIsland.keylineTint(color)
+ } else {
+ return dynamicIsland
+ }
+ }
+}
+
+// MARK: - Adaptive Lock Screen View (iOS 18+)
+
+/// A view that adapts its content based on the activity family environment
+/// - For .small (watchOS/CarPlay): Uses supplementalActivityFamiliesSmall content if available, falls back to lockScreen
+/// - For .medium (iPhone lock screen) and unknown: Always uses lockScreen
+@available(iOS 18.0, *)
+struct VoltraAdaptiveLockScreenView: View {
+ let context: ActivityViewContext
+ let rootNodeProvider: (VoltraRegion, VoltraAttributes.ContentState) -> VoltraNode
+
+ @Environment(\.activityFamily) private var activityFamily
+
+ var body: some View {
+ switch activityFamily {
+ case .small:
+ let region: VoltraRegion = context.state.regions[.supplementalActivityFamiliesSmall] != nil
+ ? .supplementalActivityFamiliesSmall
+ : .lockScreen
+ Voltra(root: rootNodeProvider(region, context.state), activityId: context.activityID)
+
+ case .medium:
+ Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID)
+
+ @unknown default:
+ Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID)
}
}
}
diff --git a/plugin/src/constants/activities.ts b/plugin/src/constants/activities.ts
new file mode 100644
index 0000000..b70e5f8
--- /dev/null
+++ b/plugin/src/constants/activities.ts
@@ -0,0 +1,13 @@
+import type { ActivityFamily } from '../types'
+
+/**
+ * Activity-related constants for the Voltra plugin
+ */
+
+/** Default supplemental activity families when not specified */
+export const DEFAULT_ACTIVITY_FAMILIES: ActivityFamily[] = ['small']
+
+/** Maps JS activity family names to SwiftUI ActivityFamily enum cases */
+export const ACTIVITY_FAMILY_MAP: Record = {
+ small: '.small',
+}
diff --git a/plugin/src/constants/index.ts b/plugin/src/constants/index.ts
index 5203efb..72ce4c0 100644
--- a/plugin/src/constants/index.ts
+++ b/plugin/src/constants/index.ts
@@ -2,6 +2,7 @@
* Re-exports all constants
*/
+export { DEFAULT_ACTIVITY_FAMILIES, ACTIVITY_FAMILY_MAP } from './activities'
export { IOS } from './ios'
export { DEFAULT_USER_IMAGES_PATH } from './paths'
export {
diff --git a/plugin/src/features/ios/files/index.ts b/plugin/src/features/ios/files/index.ts
index 4649276..ca967c9 100644
--- a/plugin/src/features/ios/files/index.ts
+++ b/plugin/src/features/ios/files/index.ts
@@ -2,7 +2,7 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins'
import * as fs from 'fs'
import * as path from 'path'
-import type { WidgetConfig } from '../../../types'
+import type { LiveActivityConfig, WidgetConfig } from '../../../types'
import { generateAssets } from './assets'
import { generateEntitlements } from './entitlements'
import { generateInfoPlist } from './plist'
@@ -12,6 +12,7 @@ export interface GenerateWidgetExtensionFilesProps {
targetName: string
widgets?: WidgetConfig[]
groupIdentifier: string
+ liveActivity?: LiveActivityConfig
}
/**
@@ -27,7 +28,7 @@ export interface GenerateWidgetExtensionFilesProps {
* This should run before configureXcodeProject so the files exist when Xcode project is configured.
*/
export const generateWidgetExtensionFiles: ConfigPlugin = (config, props) => {
- const { targetName, widgets, groupIdentifier } = props
+ const { targetName, widgets, groupIdentifier, liveActivity } = props
return withDangerousMod(config, [
'ios',
@@ -51,6 +52,7 @@ export const generateWidgetExtensionFiles: ConfigPlugin {
- const { targetPath, projectRoot, widgets } = options
+ const { targetPath, projectRoot, widgets, supplementalActivityFamilies } = options
// Prerender widget initial states if any widgets have initialStatePath configured
const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot)
@@ -35,7 +36,9 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr
// Generate the widget bundle Swift file
const widgetBundleContent =
- widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift()
+ widgets && widgets.length > 0
+ ? generateWidgetBundleSwift(widgets, supplementalActivityFamilies)
+ : generateDefaultWidgetBundleSwift(supplementalActivityFamilies)
const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift')
fs.writeFileSync(widgetBundlePath, widgetBundleContent)
diff --git a/plugin/src/features/ios/files/swift/widgetBundle.ts b/plugin/src/features/ios/files/swift/widgetBundle.ts
index 3fd9685..cde6e97 100644
--- a/plugin/src/features/ios/files/swift/widgetBundle.ts
+++ b/plugin/src/features/ios/files/swift/widgetBundle.ts
@@ -1,7 +1,32 @@
import dedent from 'dedent'
-import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../../../constants'
-import type { WidgetConfig } from '../../../../types'
+import { ACTIVITY_FAMILY_MAP, DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../../../constants'
+import type { ActivityFamily, WidgetConfig } from '../../../../types'
+
+function generateSupplementalActivityFamiliesSwift(families?: ActivityFamily[]): string | null {
+ if (!families || families.length === 0) {
+ return null
+ }
+ return families.map((f) => ACTIVITY_FAMILY_MAP[f]).join(', ')
+}
+
+function generateVoltraWidgetWrapper(familiesSwift: string): string {
+ return dedent`
+ // MARK: - Live Activity with Supplemental Activity Families
+
+ struct VoltraWidgetWithSupplementalActivityFamilies: Widget {
+ private let wrapped = VoltraWidget()
+
+ var body: some WidgetConfiguration {
+ if #available(iOS 18.0, *) {
+ return wrapped.body.supplementalActivityFamilies([${familiesSwift}])
+ } else {
+ return wrapped.body
+ }
+ }
+ }
+ `
+}
/**
* Generates Swift code for a single widget struct
@@ -41,12 +66,18 @@ function generateWidgetStruct(widget: WidgetConfig): string {
/**
* Generates the VoltraWidgetBundle.swift file content with configured widgets
*/
-export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string {
+export function generateWidgetBundleSwift(
+ widgets: WidgetConfig[],
+ supplementalActivityFamilies?: ActivityFamily[]
+): string {
// Generate widget structs
const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n')
// Generate widget bundle body entries
const widgetInstances = widgets.map((w) => ` VoltraWidget_${w.id}()`).join('\n')
+ const familiesSwift = generateSupplementalActivityFamiliesSwift(supplementalActivityFamilies)
+ const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalActivityFamilies()' : 'VoltraWidget()'
+ const wrapperStruct = familiesSwift ? '\n\n' + generateVoltraWidgetWrapper(familiesSwift) : ''
return dedent`
//
@@ -64,7 +95,7 @@ export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string {
struct VoltraWidgetBundle: WidgetBundle {
var body: some Widget {
// Live Activity Widget (Dynamic Island + Lock Screen)
- VoltraWidget()
+ ${liveActivityWidget}
// Home Screen Widgets
${widgetInstances}
@@ -73,7 +104,7 @@ export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string {
// MARK: - Home Screen Widget Definitions
- ${widgetStructs}
+ ${widgetStructs}${wrapperStruct}
`
}
@@ -81,7 +112,11 @@ export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string {
* Generates the VoltraWidgetBundle.swift file content when no widgets are configured
* (only Live Activities)
*/
-export function generateDefaultWidgetBundleSwift(): string {
+export function generateDefaultWidgetBundleSwift(supplementalActivityFamilies?: ActivityFamily[]): string {
+ const familiesSwift = generateSupplementalActivityFamiliesSwift(supplementalActivityFamilies)
+ const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalActivityFamilies()' : 'VoltraWidget()'
+ const wrapperStruct = familiesSwift ? '\n\n' + generateVoltraWidgetWrapper(familiesSwift) : ''
+
return dedent`
//
// VoltraWidgetBundle.swift
@@ -98,8 +133,8 @@ export function generateDefaultWidgetBundleSwift(): string {
struct VoltraWidgetBundle: WidgetBundle {
var body: some Widget {
// Live Activity Widget (Dynamic Island + Lock Screen)
- VoltraWidget()
+ ${liveActivityWidget}
}
- }
+ }${wrapperStruct}
`
}
diff --git a/plugin/src/features/ios/index.ts b/plugin/src/features/ios/index.ts
index 0970056..15a9548 100644
--- a/plugin/src/features/ios/index.ts
+++ b/plugin/src/features/ios/index.ts
@@ -1,6 +1,6 @@
import { ConfigPlugin, withPlugins } from '@expo/config-plugins'
-import type { WidgetConfig } from '../../types'
+import type { LiveActivityConfig, WidgetConfig } from '../../types'
import { configureEasBuild } from './eas'
import { generateWidgetExtensionFiles } from './files'
import { configureMainAppPlist } from './plist'
@@ -13,6 +13,7 @@ export interface WithIOSProps {
deploymentTarget: string
widgets?: WidgetConfig[]
groupIdentifier: string
+ liveActivity?: LiveActivityConfig
}
/**
@@ -26,11 +27,11 @@ export interface WithIOSProps {
* 5. Configure EAS build settings
*/
export const withIOS: ConfigPlugin = (config, props) => {
- const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier } = props
+ const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier, liveActivity } = props
return withPlugins(config, [
// 1. Generate widget extension files (must run first so files exist)
- [generateWidgetExtensionFiles, { targetName, widgets, groupIdentifier }],
+ [generateWidgetExtensionFiles, { targetName, widgets, groupIdentifier, liveActivity }],
// 2. Configure Xcode project (must run after files are generated)
[configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget }],
diff --git a/plugin/src/index.ts b/plugin/src/index.ts
index acb2be8..d674533 100644
--- a/plugin/src/index.ts
+++ b/plugin/src/index.ts
@@ -54,6 +54,7 @@ const withVoltra: VoltraConfigPlugin = (config, props) => {
deploymentTarget,
widgets: props.widgets,
groupIdentifier: props.groupIdentifier,
+ liveActivity: props.liveActivity,
})
// Optionally enable push notifications
diff --git a/plugin/src/types/activity.ts b/plugin/src/types/activity.ts
new file mode 100644
index 0000000..f96b2a8
--- /dev/null
+++ b/plugin/src/types/activity.ts
@@ -0,0 +1,23 @@
+/**
+ * Activity-related type definitions for Live Activities
+ */
+
+/**
+ * Supported supplemental activity families (iOS 18+)
+ * These enable Live Activities to appear on watchOS Smart Stack and CarPlay
+ */
+export type ActivityFamily = 'small'
+
+/**
+ * Configuration for Live Activity supplemental families
+ */
+export interface LiveActivityConfig {
+ /**
+ * Supplemental activity families to enable (iOS 18+)
+ * - 'small': Compact view for watchOS Smart Stack and CarPlay
+ *
+ * When configured, the .supplementalActivityFamilies() modifier is applied
+ * to the ActivityConfiguration with availability check for iOS 18.0+
+ */
+ supplementalActivityFamilies?: ActivityFamily[]
+}
diff --git a/plugin/src/types/index.ts b/plugin/src/types/index.ts
index 5899aa0..7a641ce 100644
--- a/plugin/src/types/index.ts
+++ b/plugin/src/types/index.ts
@@ -2,5 +2,6 @@
* Public type exports for the Voltra plugin
*/
+export type { ActivityFamily, LiveActivityConfig } from './activity'
export type { ConfigPluginProps, IOSPluginProps, VoltraConfigPlugin } from './plugin'
export type { WidgetConfig, WidgetFamily, WidgetFiles } from './widget'
diff --git a/plugin/src/types/plugin.ts b/plugin/src/types/plugin.ts
index a1b1e1d..24b058b 100644
--- a/plugin/src/types/plugin.ts
+++ b/plugin/src/types/plugin.ts
@@ -1,5 +1,6 @@
import { ConfigPlugin } from '@expo/config-plugins'
+import type { LiveActivityConfig } from './activity'
import type { WidgetConfig } from './widget'
/**
@@ -24,6 +25,10 @@ export interface ConfigPluginProps {
* If not provided, will use the main app's deployment target or fall back to the default
*/
deploymentTarget?: string
+ /**
+ * Configuration for Live Activities
+ */
+ liveActivity?: LiveActivityConfig
}
/**
@@ -42,4 +47,5 @@ export interface IOSPluginProps {
groupIdentifier: string
projectRoot: string
platformProjectRoot: string
+ liveActivity?: LiveActivityConfig
}
diff --git a/plugin/src/validation/index.ts b/plugin/src/validation/index.ts
index 585b382..523fa41 100644
--- a/plugin/src/validation/index.ts
+++ b/plugin/src/validation/index.ts
@@ -2,5 +2,6 @@
* Validation exports
*/
+export { validateLiveActivityConfig } from './validateActivity'
export { validateProps } from './validateProps'
export { validateWidgetConfig } from './validateWidget'
diff --git a/plugin/src/validation/validateActivity.ts b/plugin/src/validation/validateActivity.ts
new file mode 100644
index 0000000..2575942
--- /dev/null
+++ b/plugin/src/validation/validateActivity.ts
@@ -0,0 +1,34 @@
+import type { ActivityFamily, LiveActivityConfig } from '../types'
+
+const VALID_ACTIVITY_FAMILIES: Set = new Set(['small'])
+
+/**
+ * Validates a Live Activity configuration.
+ * Throws an error if validation fails.
+ */
+export function validateLiveActivityConfig(config: LiveActivityConfig | undefined): void {
+ if (!config) return
+
+ // Validate supplemental families if provided
+ if (config.supplementalActivityFamilies) {
+ if (!Array.isArray(config.supplementalActivityFamilies)) {
+ throw new Error('liveActivity.supplementalActivityFamilies must be an array')
+ }
+
+ if (config.supplementalActivityFamilies.length === 0) {
+ throw new Error(
+ 'liveActivity.supplementalActivityFamilies cannot be empty. ' +
+ 'Either provide families or remove the property to disable supplemental activity families.'
+ )
+ }
+
+ for (const family of config.supplementalActivityFamilies) {
+ if (!VALID_ACTIVITY_FAMILIES.has(family)) {
+ throw new Error(
+ `Invalid activity family '${family}'. ` +
+ `Valid families are: ${Array.from(VALID_ACTIVITY_FAMILIES).join(', ')}`
+ )
+ }
+ }
+ }
+}
diff --git a/plugin/src/validation/validateProps.ts b/plugin/src/validation/validateProps.ts
index e36edd9..6f48c6f 100644
--- a/plugin/src/validation/validateProps.ts
+++ b/plugin/src/validation/validateProps.ts
@@ -1,4 +1,5 @@
import type { ConfigPluginProps } from '../types'
+import { validateLiveActivityConfig } from './validateActivity'
import { validateWidgetConfig } from './validateWidget'
/**
@@ -43,4 +44,8 @@ export function validateProps(props: ConfigPluginProps | undefined): void {
seenIds.add(widget.id)
}
}
+
+ if (props.liveActivity) {
+ validateLiveActivityConfig(props.liveActivity)
+ }
}
diff --git a/src/live-activity/__tests__/variants.node.test.tsx b/src/live-activity/__tests__/variants.node.test.tsx
index e66da96..4d7b597 100644
--- a/src/live-activity/__tests__/variants.node.test.tsx
+++ b/src/live-activity/__tests__/variants.node.test.tsx
@@ -32,3 +32,80 @@ describe('Variants', () => {
expect(result).toHaveProperty('isl_min')
})
})
+
+describe('Supplemental Activity Families (iOS 18+)', () => {
+ test('supplementalActivityFamilies.small renders to saf_sm key', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock Screen,
+ supplementalActivityFamilies: {
+ small: Watch,
+ },
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).toHaveProperty('saf_sm')
+ })
+
+ test('supplementalActivityFamilies.small content is rendered correctly', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ supplementalActivityFamilies: {
+ small: (
+
+ Watch Content
+
+ ),
+ },
+ })
+
+ expect(result.saf_sm).toBeDefined()
+ expect(result.saf_sm.t).toBe(11)
+ expect(result.saf_sm.c.t).toBe(0)
+ expect(result.saf_sm.c.c).toBe('Watch Content')
+ })
+
+ test('supplementalActivityFamilies families work with all other variants', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ island: {
+ expanded: {
+ center: Center,
+ },
+ compact: {
+ leading: CL,
+ trailing: CT,
+ },
+ minimal: Min,
+ },
+ supplementalActivityFamilies: {
+ small: Watch,
+ },
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).toHaveProperty('isl_exp_c')
+ expect(result).toHaveProperty('isl_cmp_l')
+ expect(result).toHaveProperty('isl_cmp_t')
+ expect(result).toHaveProperty('isl_min')
+ expect(result).toHaveProperty('saf_sm')
+ })
+
+ test('omitting supplementalActivityFamilies.small does not add saf_sm key', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).not.toHaveProperty('saf_sm')
+ })
+
+ test('empty supplementalActivityFamilies object does not add saf_sm key', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ supplementalActivityFamilies: {},
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).not.toHaveProperty('saf_sm')
+ })
+})
diff --git a/src/live-activity/renderer.ts b/src/live-activity/renderer.ts
index 3b272ca..f048a0f 100644
--- a/src/live-activity/renderer.ts
+++ b/src/live-activity/renderer.ts
@@ -51,6 +51,13 @@ export const renderLiveActivityToJson = (variants: LiveActivityVariants): LiveAc
}
}
+ // Add supplemental activity family variants (iOS 18+)
+ if (variants.supplementalActivityFamilies) {
+ if (variants.supplementalActivityFamilies.small) {
+ renderer.addRootNode('saf_sm', variants.supplementalActivityFamilies.small)
+ }
+ }
+
// Render all variants
const result = renderer.render() as LiveActivityJson
diff --git a/src/live-activity/types.ts b/src/live-activity/types.ts
index 9b659fc..de3f9be 100644
--- a/src/live-activity/types.ts
+++ b/src/live-activity/types.ts
@@ -26,6 +26,18 @@ export type LiveActivityVariants = {
}
minimal?: ReactNode
}
+ /**
+ * Supplemental activity families for iOS 18+ (watchOS Smart Stack, CarPlay)
+ * Requires plugin config: liveActivity.supplementalActivityFamilies: ["small"]
+ */
+ supplementalActivityFamilies?: {
+ /**
+ * Compact view for watchOS Smart Stack and CarPlay (iOS 18+)
+ * Should be a simplified version of the lock screen UI
+ * Falls back to lockScreen content if not provided
+ */
+ small?: ReactNode
+ }
}
/**
@@ -45,6 +57,8 @@ export type LiveActivityVariantsJson = {
isl_cmp_l?: VoltraNodeJson
isl_cmp_t?: VoltraNodeJson
isl_min?: VoltraNodeJson
+ // Supplemental activity families (iOS 18+)
+ saf_sm?: VoltraNodeJson // supplementalActivityFamilies.small
}
/**
diff --git a/src/plugin/__tests__/supplemental-families.node.test.ts b/src/plugin/__tests__/supplemental-families.node.test.ts
new file mode 100644
index 0000000..9a4ea6c
--- /dev/null
+++ b/src/plugin/__tests__/supplemental-families.node.test.ts
@@ -0,0 +1,91 @@
+import { validateLiveActivityConfig } from '../../../plugin/build/validation/validateActivity.js'
+import {
+ generateDefaultWidgetBundleSwift,
+ generateWidgetBundleSwift,
+} from '../../../plugin/build/features/ios/files/swift/widgetBundle.js'
+
+describe('validateLiveActivityConfig', () => {
+ test('undefined config is valid', () => {
+ expect(() => validateLiveActivityConfig(undefined)).not.toThrow()
+ })
+
+ test('empty config is valid', () => {
+ expect(() => validateLiveActivityConfig({})).not.toThrow()
+ })
+
+ test('valid supplementalActivityFamilies with "small" passes', () => {
+ expect(() =>
+ validateLiveActivityConfig({
+ supplementalActivityFamilies: ['small'],
+ })
+ ).not.toThrow()
+ })
+
+ test('empty supplementalActivityFamilies array throws', () => {
+ expect(() =>
+ validateLiveActivityConfig({
+ supplementalActivityFamilies: [],
+ })
+ ).toThrow('liveActivity.supplementalActivityFamilies cannot be empty')
+ })
+
+ test('invalid family name throws', () => {
+ expect(() =>
+ validateLiveActivityConfig({
+ supplementalActivityFamilies: ['medium' as any],
+ })
+ ).toThrow("Invalid activity family 'medium'")
+ })
+
+ test('non-array supplementalActivityFamilies throws', () => {
+ expect(() =>
+ validateLiveActivityConfig({
+ supplementalActivityFamilies: 'small' as any,
+ })
+ ).toThrow('liveActivity.supplementalActivityFamilies must be an array')
+ })
+})
+
+describe('generateDefaultWidgetBundleSwift', () => {
+ test('without supplementalActivityFamilies uses VoltraWidget directly', () => {
+ const result = generateDefaultWidgetBundleSwift()
+
+ expect(result).toContain('VoltraWidget()')
+ expect(result).not.toContain('VoltraWidgetWithSupplementalActivityFamilies')
+ expect(result).not.toContain('.supplementalActivityFamilies')
+ })
+
+ test('with supplementalActivityFamilies generates wrapper', () => {
+ const result = generateDefaultWidgetBundleSwift(['small'])
+
+ expect(result).toContain('VoltraWidgetWithSupplementalActivityFamilies()')
+ expect(result).toContain('struct VoltraWidgetWithSupplementalActivityFamilies: Widget')
+ expect(result).toContain('.supplementalActivityFamilies([.small])')
+ expect(result).toContain('#available(iOS 18.0, *)')
+ })
+})
+
+describe('generateWidgetBundleSwift', () => {
+ const testWidget = {
+ id: 'test',
+ displayName: 'Test Widget',
+ description: 'A test widget',
+ }
+
+ test('without supplementalActivityFamilies uses VoltraWidget directly', () => {
+ const result = generateWidgetBundleSwift([testWidget])
+
+ expect(result).toContain('VoltraWidget()')
+ expect(result).not.toContain('VoltraWidgetWithSupplementalActivityFamilies')
+ expect(result).toContain('struct VoltraWidget_test: Widget')
+ })
+
+ test('with supplementalActivityFamilies generates wrapper alongside widgets', () => {
+ const result = generateWidgetBundleSwift([testWidget], ['small'])
+
+ expect(result).toContain('VoltraWidgetWithSupplementalActivityFamilies()')
+ expect(result).toContain('struct VoltraWidgetWithSupplementalActivityFamilies: Widget')
+ expect(result).toContain('.supplementalActivityFamilies([.small])')
+ expect(result).toContain('struct VoltraWidget_test: Widget')
+ })
+})
diff --git a/website/docs/api/plugin-configuration.md b/website/docs/api/plugin-configuration.md
index ffedb46..4d683ae 100644
--- a/website/docs/api/plugin-configuration.md
+++ b/website/docs/api/plugin-configuration.md
@@ -33,6 +33,7 @@ The Voltra Expo config plugin accepts several configuration options in your `app
### `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
@@ -56,6 +57,35 @@ iOS deployment target version for the widget extension. If not provided, default
**Note:** Code signing settings (development team, provisioning profiles) are automatically synchronized from the main app target, but the deployment target can be set independently.
+### `liveActivity` (optional)
+
+Configuration for Live Activity features, including iOS 18+ supplemental activity families.
+
+**Type:** `LiveActivityConfig`
+
+```typescript
+interface LiveActivityConfig {
+ /**
+ * Supplemental activity families to enable (iOS 18+)
+ * When configured, Live Activities will appear on watchOS Smart Stack
+ * Currently only "small" is supported
+ */
+ supplementalActivityFamilies?: 'small'[]
+}
+```
+
+**Example:**
+
+```json
+{
+ "liveActivity": {
+ "supplementalActivityFamilies": ["small"]
+ }
+}
+```
+
+See [Supplemental Activity Families](/development/supplemental-activity-families) for detailed usage.
+
### `widgets` (optional)
Array of widget configurations for Home Screen widgets. Each widget will be available in the iOS widget gallery.
@@ -83,4 +113,3 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai
]
}
```
-
diff --git a/website/docs/development/_meta.json b/website/docs/development/_meta.json
index 79513cc..83fc6c9 100644
--- a/website/docs/development/_meta.json
+++ b/website/docs/development/_meta.json
@@ -9,6 +9,11 @@
"name": "developing-live-activities",
"label": "Developing Live Activities"
},
+ {
+ "type": "file",
+ "name": "supplemental-activity-families",
+ "label": "Supplemental Activity Families"
+ },
{
"type": "file",
"name": "developing-widgets",
diff --git a/website/docs/development/developing-live-activities.md b/website/docs/development/developing-live-activities.md
index af9df21..79117bf 100644
--- a/website/docs/development/developing-live-activities.md
+++ b/website/docs/development/developing-live-activities.md
@@ -47,6 +47,27 @@ const variants = {
}
```
+### Supplemental Activity Families (iOS 18+)
+
+The `supplementalActivityFamilies` variant defines how your Live Activity appears on watchOS Smart Stack and CarPlay displays. This requires explicit opt-in via the plugin configuration.
+
+```typescript
+const variants = {
+ supplementalActivityFamilies: {
+ small: (
+
+ 12 min
+ ETA
+
+ ),
+ },
+}
+```
+
+If `supplementalActivityFamilies.small` is not provided, the system will automatically fall back to using your `lockScreen` content.
+
+See [Supplemental Activity Families](/development/supplemental-activity-families) for detailed configuration and design guidelines.
+
## useLiveActivity
For React development, Voltra provides the `useLiveActivity` hook for integration with the component lifecycle and automatic updates during development.