From 2fcfa62ec292531f4cc8dfb2de066d8a687d58ae Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:22:36 +0700 Subject: [PATCH 01/18] feat(types): add supplemental activity families to LiveActivityVariants --- src/live-activity/types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/live-activity/types.ts b/src/live-activity/types.ts index 9b659fc..f08795d 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.supplementalFamilies: ["small"] + */ + supplemental?: { + /** + * 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+) + sup_sm?: VoltraNodeJson // supplemental.small } /** From 550a48dc78463e5669a24fab1e61879ec2156ef8 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:26:36 +0700 Subject: [PATCH 02/18] feat(renderer): add supplemental family region rendering --- src/live-activity/renderer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/live-activity/renderer.ts b/src/live-activity/renderer.ts index 3b272ca..3f84337 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.supplemental) { + if (variants.supplemental.small) { + renderer.addRootNode('sup_sm', variants.supplemental.small) + } + } + // Render all variants const result = renderer.render() as LiveActivityJson From 4f92fd83268f0b37656ffbd72bde6f143c5888fa Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:29:21 +0700 Subject: [PATCH 03/18] feat(swift): add supplemental family cases to VoltraRegion --- ios/shared/VoltraRegion.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/shared/VoltraRegion.swift b/ios/shared/VoltraRegion.swift index f27d914..46d73f1 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 supplementalSmall /// 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 .supplementalSmall: + return "sup_sm" } } } From 8cf3bd7879d57987e055f2eb0560bea9a5acbd92 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:31:28 +0700 Subject: [PATCH 04/18] feat(swift): add iOS 18 supplementalActivityFamilies support with adaptive view --- ios/target/VoltraWidget.swift | 127 +++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 31 deletions(-) diff --git a/ios/target/VoltraWidget.swift b/ios/target/VoltraWidget.swift index dda283f..045d3a9 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 ios18Configuration() + } else { + return legacyConfiguration() + } + } + + // MARK: - iOS 18+ Configuration (with adaptive view for supplemental families) + + @available(iOS 18.0, *) + private func ios18Configuration() -> 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 VoltraWidgetWithSupplementalFamilies + // wrapper when configured via plugin (see VoltraWidgetBundle.swift) + } + + // MARK: - Legacy Configuration (iOS 16.2 - 17.x) + + private func legacyConfiguration() -> 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,76 @@ 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 supplementalSmall 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: + // watchOS Smart Stack / CarPlay: prefer supplementalSmall, fallback to lockScreen + let region: VoltraRegion = context.state.regions[.supplementalSmall] != nil + ? .supplementalSmall + : .lockScreen + Voltra(root: rootNodeProvider(region, context.state), activityId: context.activityID) + + case .medium, @unknown default: + // iPhone lock screen: always use lockScreen + Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID) } } } From 0b961ce49c6631e5c52c7347a797b41c44426feb Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:33:24 +0700 Subject: [PATCH 05/18] feat(plugin): add ActivityFamily types for supplemental families config --- plugin/src/types/activity.ts | 23 +++++++++++++++++++++++ plugin/src/types/index.ts | 1 + plugin/src/types/plugin.ts | 5 +++++ 3 files changed, 29 insertions(+) create mode 100644 plugin/src/types/activity.ts diff --git a/plugin/src/types/activity.ts b/plugin/src/types/activity.ts new file mode 100644 index 0000000..0fd89d3 --- /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+ + */ + supplementalFamilies?: 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..9b0e5fc 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 (iOS 18+ features) + */ + liveActivity?: LiveActivityConfig } /** From 9b1c236e312c43be728299663f7be3809d01981a Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:33:49 +0700 Subject: [PATCH 06/18] feat(plugin): add activity family constants and Swift mapping --- plugin/src/constants/activities.ts | 13 +++++++++++++ plugin/src/constants/index.ts | 1 + 2 files changed, 14 insertions(+) create mode 100644 plugin/src/constants/activities.ts 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 { From 6f962ffdeee20b4957d130e3672b234f9ac603e5 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:37:47 +0700 Subject: [PATCH 07/18] feat(plugin): add validation for supplemental activity families --- plugin/src/validation/index.ts | 1 + plugin/src/validation/validateActivity.ts | 34 +++++++++++++++++++++++ plugin/src/validation/validateProps.ts | 5 ++++ 3 files changed, 40 insertions(+) create mode 100644 plugin/src/validation/validateActivity.ts 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..770822c --- /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.supplementalFamilies) { + if (!Array.isArray(config.supplementalFamilies)) { + throw new Error('liveActivity.supplementalFamilies must be an array') + } + + if (config.supplementalFamilies.length === 0) { + throw new Error( + 'liveActivity.supplementalFamilies cannot be empty. ' + + 'Either provide families or remove the property to disable supplemental families.' + ) + } + + for (const family of config.supplementalFamilies) { + 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) + } } From 678dce58608f006e2ca3b77f705003617e1b1d48 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:39:15 +0700 Subject: [PATCH 08/18] feat(plugin): generate supplementalActivityFamilies in widget bundle --- .../features/ios/files/swift/widgetBundle.ts | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/plugin/src/features/ios/files/swift/widgetBundle.ts b/plugin/src/features/ios/files/swift/widgetBundle.ts index 3fd9685..83e9dd5 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 generateSupplementalFamiliesSwift(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 Families + + struct VoltraWidgetWithSupplementalFamilies: 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,12 @@ function generateWidgetStruct(widget: WidgetConfig): string { /** * Generates the VoltraWidgetBundle.swift file content with configured widgets */ -export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string { - // Generate widget structs +export function generateWidgetBundleSwift(widgets: WidgetConfig[], supplementalFamilies?: ActivityFamily[]): string { 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 = generateSupplementalFamiliesSwift(supplementalFamilies) + const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalFamilies()' : 'VoltraWidget()' + const wrapperStruct = familiesSwift ? '\n\n' + generateVoltraWidgetWrapper(familiesSwift) : '' return dedent` // @@ -63,17 +88,15 @@ export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string { @main struct VoltraWidgetBundle: WidgetBundle { var body: some Widget { - // Live Activity Widget (Dynamic Island + Lock Screen) - VoltraWidget() + ${liveActivityWidget} - // Home Screen Widgets ${widgetInstances} } } // MARK: - Home Screen Widget Definitions - ${widgetStructs} + ${widgetStructs}${wrapperStruct} ` } @@ -81,7 +104,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(supplementalFamilies?: ActivityFamily[]): string { + const familiesSwift = generateSupplementalFamiliesSwift(supplementalFamilies) + const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalFamilies()' : 'VoltraWidget()' + const wrapperStruct = familiesSwift ? '\n\n' + generateVoltraWidgetWrapper(familiesSwift) : '' + return dedent` // // VoltraWidgetBundle.swift @@ -92,14 +119,13 @@ export function generateDefaultWidgetBundleSwift(): string { import SwiftUI import WidgetKit - import VoltraWidget // Import Voltra widgets + import VoltraWidget @main struct VoltraWidgetBundle: WidgetBundle { var body: some Widget { - // Live Activity Widget (Dynamic Island + Lock Screen) - VoltraWidget() + ${liveActivityWidget} } - } + }${wrapperStruct} ` } From 0fc1175a4094d9c81ee3fe0d2107ee331125659b Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:41:54 +0700 Subject: [PATCH 09/18] feat(plugin): wire up liveActivity config to iOS generation --- plugin/src/features/ios/files/index.ts | 11 ++++------- plugin/src/features/ios/files/swift/index.ts | 12 ++++++------ plugin/src/features/ios/index.ts | 8 ++++---- plugin/src/index.ts | 1 + plugin/src/types/plugin.ts | 1 + 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/plugin/src/features/ios/files/index.ts b/plugin/src/features/ios/files/index.ts index 4649276..917fcb7 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', @@ -35,25 +36,21 @@ export const generateWidgetExtensionFiles: ConfigPlugin { - const { targetPath, projectRoot, widgets } = options + const { targetPath, projectRoot, widgets, supplementalFamilies } = options - // Prerender widget initial states if any widgets have initialStatePath configured const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot) - // Generate the initial states Swift file const initialStatesContent = generateInitialStatesSwift(prerenderedStates) const initialStatesPath = path.join(targetPath, 'VoltraWidgetInitialStates.swift') fs.writeFileSync(initialStatesPath, initialStatesContent) logger.info(`Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} pre-rendered widget states`) - // Generate the widget bundle Swift file const widgetBundleContent = - widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift() + widgets && widgets.length > 0 + ? generateWidgetBundleSwift(widgets, supplementalFamilies) + : generateDefaultWidgetBundleSwift(supplementalFamilies) const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift') fs.writeFileSync(widgetBundlePath, widgetBundleContent) diff --git a/plugin/src/features/ios/index.ts b/plugin/src/features/ios/index.ts index 0970056..36f2253 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,10 @@ 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/plugin.ts b/plugin/src/types/plugin.ts index 9b0e5fc..9b0ecd4 100644 --- a/plugin/src/types/plugin.ts +++ b/plugin/src/types/plugin.ts @@ -47,4 +47,5 @@ export interface IOSPluginProps { groupIdentifier: string projectRoot: string platformProjectRoot: string + liveActivity?: LiveActivityConfig } From ed1ae5380c1d29207fddee83d6c79782907f54ea Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 00:50:14 +0700 Subject: [PATCH 10/18] test: add tests for supplemental families and example Watch demo - Add renderer tests for supplemental.small variant serialization - Add plugin tests for validation and widget bundle generation - Add WatchLiveActivity example demonstrating supplemental.small - Enable supplementalFamilies in example app config --- example/app.json | 3 + .../live-activities/WatchLiveActivityUI.tsx | 59 ++++++++++++ .../live-activities/LiveActivitiesScreen.tsx | 17 +++- .../live-activities/WatchLiveActivity.tsx | 48 ++++++++++ .../__tests__/variants.node.test.tsx | 77 ++++++++++++++++ .../supplemental-families.node.test.ts | 91 +++++++++++++++++++ 6 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 example/components/live-activities/WatchLiveActivityUI.tsx create mode 100644 example/screens/live-activities/WatchLiveActivity.tsx create mode 100644 src/plugin/__tests__/supplemental-families.node.test.ts diff --git a/example/app.json b/example/app.json index 55bae56..84baf88 100644 --- a/example/app.json +++ b/example/app.json @@ -24,6 +24,9 @@ { "groupIdentifier": "group.callstackincubator.voltraexample", "enablePushNotifications": true, + "liveActivity": { + "supplementalFamilies": ["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..ffb6711 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..ef5e6c4 --- /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: , + }, + supplemental: { + 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/src/live-activity/__tests__/variants.node.test.tsx b/src/live-activity/__tests__/variants.node.test.tsx index e66da96..e09cab6 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('supplemental.small renders to sup_sm key', async () => { + const result = await renderLiveActivityToJson({ + lockScreen: Lock Screen, + supplemental: { + small: Watch, + }, + }) + + expect(result).toHaveProperty('ls') + expect(result).toHaveProperty('sup_sm') + }) + + test('supplemental.small content is rendered correctly', async () => { + const result = await renderLiveActivityToJson({ + lockScreen: Lock, + supplemental: { + small: ( + + Watch Content + + ), + }, + }) + + expect(result.sup_sm).toBeDefined() + expect(result.sup_sm.t).toBe(11) + expect(result.sup_sm.c.t).toBe(0) + expect(result.sup_sm.c.c).toBe('Watch Content') + }) + + test('supplemental families work with all other variants', async () => { + const result = await renderLiveActivityToJson({ + lockScreen: Lock, + island: { + expanded: { + center: Center, + }, + compact: { + leading: CL, + trailing: CT, + }, + minimal: Min, + }, + supplemental: { + 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('sup_sm') + }) + + test('omitting supplemental.small does not add sup_sm key', async () => { + const result = await renderLiveActivityToJson({ + lockScreen: Lock, + }) + + expect(result).toHaveProperty('ls') + expect(result).not.toHaveProperty('sup_sm') + }) + + test('empty supplemental object does not add sup_sm key', async () => { + const result = await renderLiveActivityToJson({ + lockScreen: Lock, + supplemental: {}, + }) + + expect(result).toHaveProperty('ls') + expect(result).not.toHaveProperty('sup_sm') + }) +}) 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..5d59eb0 --- /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 supplementalFamilies with "small" passes', () => { + expect(() => + validateLiveActivityConfig({ + supplementalFamilies: ['small'], + }) + ).not.toThrow() + }) + + test('empty supplementalFamilies array throws', () => { + expect(() => + validateLiveActivityConfig({ + supplementalFamilies: [], + }) + ).toThrow('liveActivity.supplementalFamilies cannot be empty') + }) + + test('invalid family name throws', () => { + expect(() => + validateLiveActivityConfig({ + supplementalFamilies: ['medium' as any], + }) + ).toThrow("Invalid activity family 'medium'") + }) + + test('non-array supplementalFamilies throws', () => { + expect(() => + validateLiveActivityConfig({ + supplementalFamilies: 'small' as any, + }) + ).toThrow('liveActivity.supplementalFamilies must be an array') + }) +}) + +describe('generateDefaultWidgetBundleSwift', () => { + test('without supplemental families uses VoltraWidget directly', () => { + const result = generateDefaultWidgetBundleSwift() + + expect(result).toContain('VoltraWidget()') + expect(result).not.toContain('VoltraWidgetWithSupplementalFamilies') + expect(result).not.toContain('.supplementalActivityFamilies') + }) + + test('with supplemental families generates wrapper', () => { + const result = generateDefaultWidgetBundleSwift(['small']) + + expect(result).toContain('VoltraWidgetWithSupplementalFamilies()') + expect(result).toContain('struct VoltraWidgetWithSupplementalFamilies: 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 supplemental families uses VoltraWidget directly', () => { + const result = generateWidgetBundleSwift([testWidget]) + + expect(result).toContain('VoltraWidget()') + expect(result).not.toContain('VoltraWidgetWithSupplementalFamilies') + expect(result).toContain('struct VoltraWidget_test: Widget') + }) + + test('with supplemental families generates wrapper alongside widgets', () => { + const result = generateWidgetBundleSwift([testWidget], ['small']) + + expect(result).toContain('VoltraWidgetWithSupplementalFamilies()') + expect(result).toContain('struct VoltraWidgetWithSupplementalFamilies: Widget') + expect(result).toContain('.supplementalActivityFamilies([.small])') + expect(result).toContain('struct VoltraWidget_test: Widget') + }) +}) From 696414e3aa98ba8b6364e1fd13f2d2ffe967bbb0 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 01:48:11 +0700 Subject: [PATCH 11/18] fix(swift): separate @unknown default into own case in switch statement Swift syntax requires @unknown default to be its own case, cannot combine with other patterns. --- ios/target/VoltraWidget.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ios/target/VoltraWidget.swift b/ios/target/VoltraWidget.swift index 045d3a9..f6ba29a 100644 --- a/ios/target/VoltraWidget.swift +++ b/ios/target/VoltraWidget.swift @@ -114,14 +114,15 @@ struct VoltraAdaptiveLockScreenView: View { var body: some View { switch activityFamily { case .small: - // watchOS Smart Stack / CarPlay: prefer supplementalSmall, fallback to lockScreen let region: VoltraRegion = context.state.regions[.supplementalSmall] != nil ? .supplementalSmall : .lockScreen Voltra(root: rootNodeProvider(region, context.state), activityId: context.activityID) - case .medium, @unknown default: - // iPhone lock screen: always use lockScreen + case .medium: + Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID) + + @unknown default: Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID) } } From 80c84285ee2612808888212f250b6ac42dee707a Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 02:28:49 +0700 Subject: [PATCH 12/18] docs: add supplemental activity families documentation for website - Add comprehensive guide at development/supplemental-activity-families.md - Update plugin-configuration.md with liveActivity config section - Add navigation entry in development/_meta.json - Reference supplemental families in developing-live-activities.md --- website/docs/api/plugin-configuration.md | 29 +++ website/docs/development/_meta.json | 5 + .../development/developing-live-activities.md | 21 ++ .../supplemental-activity-families.md | 223 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 website/docs/development/supplemental-activity-families.md diff --git a/website/docs/api/plugin-configuration.md b/website/docs/api/plugin-configuration.md index ffedb46..e8bce05 100644 --- a/website/docs/api/plugin-configuration.md +++ b/website/docs/api/plugin-configuration.md @@ -56,6 +56,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 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 + */ + supplementalFamilies?: ('small')[] +} +``` + +**Example:** + +```json +{ + "liveActivity": { + "supplementalFamilies": ["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. 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..7fe0025 100644 --- a/website/docs/development/developing-live-activities.md +++ b/website/docs/development/developing-live-activities.md @@ -47,6 +47,27 @@ const variants = { } ``` +### Supplemental Families (iOS 18+) + +The `supplemental` 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 = { + supplemental: { + small: ( + + 12 min + ETA + + ), + }, +} +``` + +If `supplemental.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. diff --git a/website/docs/development/supplemental-activity-families.md b/website/docs/development/supplemental-activity-families.md new file mode 100644 index 0000000..94db27d --- /dev/null +++ b/website/docs/development/supplemental-activity-families.md @@ -0,0 +1,223 @@ +# Supplemental Activity Families + +Starting with iOS 18, Live Activities can appear on additional surfaces beyond the iPhone lock screen and Dynamic Island: + +- **watchOS Smart Stack** (iOS 18+) - Appears on paired Apple Watch +- **CarPlay Dashboard** (iOS 26+) - Appears on CarPlay displays + +Voltra supports the `.small` supplemental activity family, which enables your Live Activities to appear on these new surfaces. + +## Enabling supplemental families + +To enable supplemental activity families, add the `liveActivity` configuration to your plugin: + +```json +{ + "expo": { + "plugins": [ + [ + "voltra", + { + "groupIdentifier": "group.com.example", + "liveActivity": { + "supplementalFamilies": ["small"] + } + } + ] + ] + } +} +``` + +:::warning +This is an opt-in feature. Only include `"small"` if you want your Live Activities to appear on watchOS Smart Stack. +::: + +## Providing supplemental content + +Use the `supplemental.small` property in your variants to provide a compact layout optimized for watch and car surfaces: + +```tsx +import { useLiveActivity } from 'voltra/client' +import { Voltra } from 'voltra' + +function DeliveryActivity({ orderId, eta }) { + const { start, update, end } = useLiveActivity( + { + // Full lock screen UI + lockScreen: ( + + + Order #{orderId} + + + Driver en route - ETA {eta} min + + {/* Full map or detailed progress would go here */} + + ), + + // Dynamic Island variants + island: { + compact: { + leading: , + trailing: {eta} min, + }, + minimal: , + }, + + // Supplemental family for watchOS Smart Stack (iOS 18+) + supplemental: { + small: ( + + + {eta} min + + + En route + + + ), + }, + }, + { + activityName: `delivery-${orderId}`, + } + ) + + return null +} +``` + +## Fallback behavior + +If you enable `supplementalFamilies: ["small"]` in your plugin config but don't provide a `supplemental.small` variant in your `useLiveActivity` call, the system will automatically fall back to using your `lockScreen` content. + +This allows you to: + +1. Enable the capability once in your plugin config +2. Gradually add `supplemental.small` variants only where needed + +## Design guidelines + +When designing for `supplemental.small`, keep these guidelines in mind: + +### Keep it minimal + +Watch surfaces have very limited space. Show only the most essential information - typically 1-2 key metrics. + +```tsx +// Good: Essential information only +supplemental: { + small: ( + + 12 min + ETA + + ) +} + +// Avoid: Too much detail for watch display +supplemental: { + small: ( + + Order #12345 + Driver: John Smith + ETA: 12 minutes + Distance: 2.3 miles + + ) +} +``` + +### Use large, legible text + +Smaller fonts are hard to read at a glance on a watch. Use larger font sizes and bold weights for key information. + +```tsx + + 25:42 + +``` + +### High contrast + +Ensure your content is readable in various lighting conditions by using high-contrast color combinations. + +### No interactive elements + +Unlike lock screen, supplemental views are for quick glances only. Avoid buttons or toggles - users cannot interact with them on the watch. + +## iOS version requirements + +| Feature | Minimum iOS Version | +| ------- | ------------------- | +| watchOS Smart Stack | iOS 18.0 | +| CarPlay Dashboard | iOS 26.0 (future) | + +Live Activities on devices running iOS 16.2-17.x will continue to work normally on the iPhone lock screen and Dynamic Island. The supplemental content is simply ignored on older versions. + +## How it works + +When you configure `supplementalFamilies`, the Voltra plugin generates a widget wrapper that applies Apple's `.supplementalActivityFamilies()` modifier with an iOS 18 availability check: + +```swift +// Generated by Voltra config plugin +struct VoltraWidgetWithSupplementalFamilies: Widget { + private let wrapped = VoltraWidget() + + var body: some WidgetConfiguration { + if #available(iOS 18.0, *) { + return wrapped.body.supplementalActivityFamilies([.small]) + } else { + return wrapped.body + } + } +} +``` + +The Swift side uses the `@Environment(\.activityFamily)` property to detect whether content is being displayed on a `.small` (watchOS/CarPlay) or `.medium` (iPhone lock screen) surface, automatically choosing the appropriate content. + +## API reference + +### Plugin configuration + +```typescript +interface LiveActivityConfig { + /** + * Supplemental activity families to enable (iOS 18+) + * Currently only "small" is available + */ + supplementalFamilies?: ('small')[] +} +``` + +### TypeScript variants + +```typescript +interface LiveActivityVariants { + lockScreen: ReactNode | LockScreenConfig + island?: DynamicIslandConfig + /** + * Supplemental families for iOS 18+ (watchOS Smart Stack, CarPlay) + */ + supplemental?: { + /** + * Compact view for watchOS Smart Stack and CarPlay + * Falls back to lockScreen content if not provided + */ + small?: ReactNode + } +} +``` + +## Related resources + +- [Developing Live Activities](/development/developing-live-activities) - General Live Activity development +- [Plugin Configuration](/api/plugin-configuration) - Full plugin configuration reference +- [Apple: Configuring supplemental activity families](https://developer.apple.com/documentation/activitykit/activityconfiguration/supplementalactivityfamilies(_:)) +- [WWDC24: What's new in Live Activities](https://developer.apple.com/videos/play/wwdc2024/10068/) From 1ac4a0ae5cfacabec8f0db549f9ffccce1551693 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 03:04:55 +0700 Subject: [PATCH 13/18] refactor: rename supplementalFamilies to supplementalActivityFamilies Align API naming with Apple's native .supplementalActivityFamilies() modifier. Changes: - TypeScript: supplemental -> supplementalActivityFamilies in variants - Plugin config: supplementalFamilies -> supplementalActivityFamilies - Swift: supplementalSmall -> supplementalActivityFamiliesSmall - JSON key: sup_sm -> saf_sm - Generated Swift wrapper: VoltraWidgetWithSupplementalActivityFamilies - Update all tests and documentation --- ios/shared/VoltraRegion.swift | 7 +- ios/target/VoltraWidget.swift | 10 +- plugin/src/features/ios/files/index.ts | 2 +- plugin/src/features/ios/files/swift/index.ts | 8 +- .../features/ios/files/swift/widgetBundle.ts | 21 ++-- plugin/src/types/activity.ts | 2 +- plugin/src/validation/validateActivity.ts | 14 +-- src/live-activity/renderer.ts | 6 +- src/live-activity/types.ts | 6 +- .../supplemental-families.node.test.ts | 38 ++++---- website/docs/api/plugin-configuration.md | 8 +- .../development/developing-live-activities.md | 8 +- .../supplemental-activity-families.md | 96 ++++++++----------- 13 files changed, 108 insertions(+), 118 deletions(-) diff --git a/ios/shared/VoltraRegion.swift b/ios/shared/VoltraRegion.swift index 46d73f1..b447c0d 100644 --- a/ios/shared/VoltraRegion.swift +++ b/ios/shared/VoltraRegion.swift @@ -9,9 +9,8 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable { case islandCompactLeading case islandCompactTrailing case islandMinimal - case supplementalSmall + case supplementalActivityFamiliesSmall - /// The JSON key for this region in the payload public var jsonKey: String { switch self { case .lockScreen: @@ -30,8 +29,8 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable { return "isl_cmp_t" case .islandMinimal: return "isl_min" - case .supplementalSmall: - return "sup_sm" + case .supplementalActivityFamiliesSmall: + return "saf_sm" } } } diff --git a/ios/target/VoltraWidget.swift b/ios/target/VoltraWidget.swift index f6ba29a..b53f059 100644 --- a/ios/target/VoltraWidget.swift +++ b/ios/target/VoltraWidget.swift @@ -21,7 +21,7 @@ public struct VoltraWidget: Widget { } } - // MARK: - iOS 18+ Configuration (with adaptive view for supplemental families) + // MARK: - iOS 18+ Configuration (with adaptive view for supplemental activity families) @available(iOS 18.0, *) private func ios18Configuration() -> some WidgetConfiguration { @@ -38,7 +38,7 @@ public struct VoltraWidget: Widget { } dynamicIsland: { context in dynamicIslandContent(context: context) } - // NOTE: .supplementalActivityFamilies() is applied by VoltraWidgetWithSupplementalFamilies + // NOTE: .supplementalActivityFamilies() is applied by VoltraWidgetWithSupplementalActivityFamilies // wrapper when configured via plugin (see VoltraWidgetBundle.swift) } @@ -102,7 +102,7 @@ public struct VoltraWidget: Widget { // MARK: - Adaptive Lock Screen View (iOS 18+) /// A view that adapts its content based on the activity family environment -/// - For .small (watchOS/CarPlay): Uses supplementalSmall content if available, falls back to lockScreen +/// - 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 { @@ -114,8 +114,8 @@ struct VoltraAdaptiveLockScreenView: View { var body: some View { switch activityFamily { case .small: - let region: VoltraRegion = context.state.regions[.supplementalSmall] != nil - ? .supplementalSmall + let region: VoltraRegion = context.state.regions[.supplementalActivityFamiliesSmall] != nil + ? .supplementalActivityFamiliesSmall : .lockScreen Voltra(root: rootNodeProvider(region, context.state), activityId: context.activityID) diff --git a/plugin/src/features/ios/files/index.ts b/plugin/src/features/ios/files/index.ts index 917fcb7..4c13b50 100644 --- a/plugin/src/features/ios/files/index.ts +++ b/plugin/src/features/ios/files/index.ts @@ -48,7 +48,7 @@ export const generateWidgetExtensionFiles: ConfigPlugin { - const { targetPath, projectRoot, widgets, supplementalFamilies } = options + const { targetPath, projectRoot, widgets, supplementalActivityFamilies } = options const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot) @@ -34,8 +34,8 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr const widgetBundleContent = widgets && widgets.length > 0 - ? generateWidgetBundleSwift(widgets, supplementalFamilies) - : generateDefaultWidgetBundleSwift(supplementalFamilies) + ? 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 83e9dd5..bc15ea3 100644 --- a/plugin/src/features/ios/files/swift/widgetBundle.ts +++ b/plugin/src/features/ios/files/swift/widgetBundle.ts @@ -3,7 +3,7 @@ import dedent from 'dedent' import { ACTIVITY_FAMILY_MAP, DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../../../constants' import type { ActivityFamily, WidgetConfig } from '../../../../types' -function generateSupplementalFamiliesSwift(families?: ActivityFamily[]): string | null { +function generateSupplementalActivityFamiliesSwift(families?: ActivityFamily[]): string | null { if (!families || families.length === 0) { return null } @@ -12,9 +12,9 @@ function generateSupplementalFamiliesSwift(families?: ActivityFamily[]): string function generateVoltraWidgetWrapper(familiesSwift: string): string { return dedent` - // MARK: - Live Activity with Supplemental Families + // MARK: - Live Activity with Supplemental Activity Families - struct VoltraWidgetWithSupplementalFamilies: Widget { + struct VoltraWidgetWithSupplementalActivityFamilies: Widget { private let wrapped = VoltraWidget() var body: some WidgetConfiguration { @@ -66,11 +66,14 @@ function generateWidgetStruct(widget: WidgetConfig): string { /** * Generates the VoltraWidgetBundle.swift file content with configured widgets */ -export function generateWidgetBundleSwift(widgets: WidgetConfig[], supplementalFamilies?: ActivityFamily[]): string { +export function generateWidgetBundleSwift( + widgets: WidgetConfig[], + supplementalActivityFamilies?: ActivityFamily[] +): string { const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n') const widgetInstances = widgets.map((w) => ` VoltraWidget_${w.id}()`).join('\n') - const familiesSwift = generateSupplementalFamiliesSwift(supplementalFamilies) - const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalFamilies()' : 'VoltraWidget()' + const familiesSwift = generateSupplementalActivityFamiliesSwift(supplementalActivityFamilies) + const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalActivityFamilies()' : 'VoltraWidget()' const wrapperStruct = familiesSwift ? '\n\n' + generateVoltraWidgetWrapper(familiesSwift) : '' return dedent` @@ -104,9 +107,9 @@ export function generateWidgetBundleSwift(widgets: WidgetConfig[], supplementalF * Generates the VoltraWidgetBundle.swift file content when no widgets are configured * (only Live Activities) */ -export function generateDefaultWidgetBundleSwift(supplementalFamilies?: ActivityFamily[]): string { - const familiesSwift = generateSupplementalFamiliesSwift(supplementalFamilies) - const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalFamilies()' : 'VoltraWidget()' +export function generateDefaultWidgetBundleSwift(supplementalActivityFamilies?: ActivityFamily[]): string { + const familiesSwift = generateSupplementalActivityFamiliesSwift(supplementalActivityFamilies) + const liveActivityWidget = familiesSwift ? 'VoltraWidgetWithSupplementalActivityFamilies()' : 'VoltraWidget()' const wrapperStruct = familiesSwift ? '\n\n' + generateVoltraWidgetWrapper(familiesSwift) : '' return dedent` diff --git a/plugin/src/types/activity.ts b/plugin/src/types/activity.ts index 0fd89d3..f96b2a8 100644 --- a/plugin/src/types/activity.ts +++ b/plugin/src/types/activity.ts @@ -19,5 +19,5 @@ export interface LiveActivityConfig { * When configured, the .supplementalActivityFamilies() modifier is applied * to the ActivityConfiguration with availability check for iOS 18.0+ */ - supplementalFamilies?: ActivityFamily[] + supplementalActivityFamilies?: ActivityFamily[] } diff --git a/plugin/src/validation/validateActivity.ts b/plugin/src/validation/validateActivity.ts index 770822c..2575942 100644 --- a/plugin/src/validation/validateActivity.ts +++ b/plugin/src/validation/validateActivity.ts @@ -10,19 +10,19 @@ export function validateLiveActivityConfig(config: LiveActivityConfig | undefine if (!config) return // Validate supplemental families if provided - if (config.supplementalFamilies) { - if (!Array.isArray(config.supplementalFamilies)) { - throw new Error('liveActivity.supplementalFamilies must be an array') + if (config.supplementalActivityFamilies) { + if (!Array.isArray(config.supplementalActivityFamilies)) { + throw new Error('liveActivity.supplementalActivityFamilies must be an array') } - if (config.supplementalFamilies.length === 0) { + if (config.supplementalActivityFamilies.length === 0) { throw new Error( - 'liveActivity.supplementalFamilies cannot be empty. ' + - 'Either provide families or remove the property to disable supplemental families.' + 'liveActivity.supplementalActivityFamilies cannot be empty. ' + + 'Either provide families or remove the property to disable supplemental activity families.' ) } - for (const family of config.supplementalFamilies) { + for (const family of config.supplementalActivityFamilies) { if (!VALID_ACTIVITY_FAMILIES.has(family)) { throw new Error( `Invalid activity family '${family}'. ` + diff --git a/src/live-activity/renderer.ts b/src/live-activity/renderer.ts index 3f84337..f048a0f 100644 --- a/src/live-activity/renderer.ts +++ b/src/live-activity/renderer.ts @@ -52,9 +52,9 @@ export const renderLiveActivityToJson = (variants: LiveActivityVariants): LiveAc } // Add supplemental activity family variants (iOS 18+) - if (variants.supplemental) { - if (variants.supplemental.small) { - renderer.addRootNode('sup_sm', variants.supplemental.small) + if (variants.supplementalActivityFamilies) { + if (variants.supplementalActivityFamilies.small) { + renderer.addRootNode('saf_sm', variants.supplementalActivityFamilies.small) } } diff --git a/src/live-activity/types.ts b/src/live-activity/types.ts index f08795d..de3f9be 100644 --- a/src/live-activity/types.ts +++ b/src/live-activity/types.ts @@ -28,9 +28,9 @@ export type LiveActivityVariants = { } /** * Supplemental activity families for iOS 18+ (watchOS Smart Stack, CarPlay) - * Requires plugin config: liveActivity.supplementalFamilies: ["small"] + * Requires plugin config: liveActivity.supplementalActivityFamilies: ["small"] */ - supplemental?: { + supplementalActivityFamilies?: { /** * Compact view for watchOS Smart Stack and CarPlay (iOS 18+) * Should be a simplified version of the lock screen UI @@ -58,7 +58,7 @@ export type LiveActivityVariantsJson = { isl_cmp_t?: VoltraNodeJson isl_min?: VoltraNodeJson // Supplemental activity families (iOS 18+) - sup_sm?: VoltraNodeJson // supplemental.small + 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 index 5d59eb0..9a4ea6c 100644 --- a/src/plugin/__tests__/supplemental-families.node.test.ts +++ b/src/plugin/__tests__/supplemental-families.node.test.ts @@ -13,53 +13,53 @@ describe('validateLiveActivityConfig', () => { expect(() => validateLiveActivityConfig({})).not.toThrow() }) - test('valid supplementalFamilies with "small" passes', () => { + test('valid supplementalActivityFamilies with "small" passes', () => { expect(() => validateLiveActivityConfig({ - supplementalFamilies: ['small'], + supplementalActivityFamilies: ['small'], }) ).not.toThrow() }) - test('empty supplementalFamilies array throws', () => { + test('empty supplementalActivityFamilies array throws', () => { expect(() => validateLiveActivityConfig({ - supplementalFamilies: [], + supplementalActivityFamilies: [], }) - ).toThrow('liveActivity.supplementalFamilies cannot be empty') + ).toThrow('liveActivity.supplementalActivityFamilies cannot be empty') }) test('invalid family name throws', () => { expect(() => validateLiveActivityConfig({ - supplementalFamilies: ['medium' as any], + supplementalActivityFamilies: ['medium' as any], }) ).toThrow("Invalid activity family 'medium'") }) - test('non-array supplementalFamilies throws', () => { + test('non-array supplementalActivityFamilies throws', () => { expect(() => validateLiveActivityConfig({ - supplementalFamilies: 'small' as any, + supplementalActivityFamilies: 'small' as any, }) - ).toThrow('liveActivity.supplementalFamilies must be an array') + ).toThrow('liveActivity.supplementalActivityFamilies must be an array') }) }) describe('generateDefaultWidgetBundleSwift', () => { - test('without supplemental families uses VoltraWidget directly', () => { + test('without supplementalActivityFamilies uses VoltraWidget directly', () => { const result = generateDefaultWidgetBundleSwift() expect(result).toContain('VoltraWidget()') - expect(result).not.toContain('VoltraWidgetWithSupplementalFamilies') + expect(result).not.toContain('VoltraWidgetWithSupplementalActivityFamilies') expect(result).not.toContain('.supplementalActivityFamilies') }) - test('with supplemental families generates wrapper', () => { + test('with supplementalActivityFamilies generates wrapper', () => { const result = generateDefaultWidgetBundleSwift(['small']) - expect(result).toContain('VoltraWidgetWithSupplementalFamilies()') - expect(result).toContain('struct VoltraWidgetWithSupplementalFamilies: Widget') + expect(result).toContain('VoltraWidgetWithSupplementalActivityFamilies()') + expect(result).toContain('struct VoltraWidgetWithSupplementalActivityFamilies: Widget') expect(result).toContain('.supplementalActivityFamilies([.small])') expect(result).toContain('#available(iOS 18.0, *)') }) @@ -72,19 +72,19 @@ describe('generateWidgetBundleSwift', () => { description: 'A test widget', } - test('without supplemental families uses VoltraWidget directly', () => { + test('without supplementalActivityFamilies uses VoltraWidget directly', () => { const result = generateWidgetBundleSwift([testWidget]) expect(result).toContain('VoltraWidget()') - expect(result).not.toContain('VoltraWidgetWithSupplementalFamilies') + expect(result).not.toContain('VoltraWidgetWithSupplementalActivityFamilies') expect(result).toContain('struct VoltraWidget_test: Widget') }) - test('with supplemental families generates wrapper alongside widgets', () => { + test('with supplementalActivityFamilies generates wrapper alongside widgets', () => { const result = generateWidgetBundleSwift([testWidget], ['small']) - expect(result).toContain('VoltraWidgetWithSupplementalFamilies()') - expect(result).toContain('struct VoltraWidgetWithSupplementalFamilies: Widget') + 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 e8bce05..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 @@ -58,7 +59,7 @@ iOS deployment target version for the widget extension. If not provided, default ### `liveActivity` (optional) -Configuration for Live Activity features, including iOS 18+ supplemental families. +Configuration for Live Activity features, including iOS 18+ supplemental activity families. **Type:** `LiveActivityConfig` @@ -69,7 +70,7 @@ interface LiveActivityConfig { * When configured, Live Activities will appear on watchOS Smart Stack * Currently only "small" is supported */ - supplementalFamilies?: ('small')[] + supplementalActivityFamilies?: 'small'[] } ``` @@ -78,7 +79,7 @@ interface LiveActivityConfig { ```json { "liveActivity": { - "supplementalFamilies": ["small"] + "supplementalActivityFamilies": ["small"] } } ``` @@ -112,4 +113,3 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai ] } ``` - diff --git a/website/docs/development/developing-live-activities.md b/website/docs/development/developing-live-activities.md index 7fe0025..79117bf 100644 --- a/website/docs/development/developing-live-activities.md +++ b/website/docs/development/developing-live-activities.md @@ -47,13 +47,13 @@ const variants = { } ``` -### Supplemental Families (iOS 18+) +### Supplemental Activity Families (iOS 18+) -The `supplemental` variant defines how your Live Activity appears on watchOS Smart Stack and CarPlay displays. This requires explicit opt-in via the plugin configuration. +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 = { - supplemental: { + supplementalActivityFamilies: { small: ( 12 min @@ -64,7 +64,7 @@ const variants = { } ``` -If `supplemental.small` is not provided, the system will automatically fall back to using your `lockScreen` content. +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. diff --git a/website/docs/development/supplemental-activity-families.md b/website/docs/development/supplemental-activity-families.md index 94db27d..3ca8d75 100644 --- a/website/docs/development/supplemental-activity-families.md +++ b/website/docs/development/supplemental-activity-families.md @@ -7,7 +7,7 @@ Starting with iOS 18, Live Activities can appear on additional surfaces beyond t Voltra supports the `.small` supplemental activity family, which enables your Live Activities to appear on these new surfaces. -## Enabling supplemental families +## Enabling supplemental activity families To enable supplemental activity families, add the `liveActivity` configuration to your plugin: @@ -20,7 +20,7 @@ To enable supplemental activity families, add the `liveActivity` configuration t { "groupIdentifier": "group.com.example", "liveActivity": { - "supplementalFamilies": ["small"] + "supplementalActivityFamilies": ["small"] } } ] @@ -33,9 +33,9 @@ To enable supplemental activity families, add the `liveActivity` configuration t This is an opt-in feature. Only include `"small"` if you want your Live Activities to appear on watchOS Smart Stack. ::: -## Providing supplemental content +## Providing supplemental activity families content -Use the `supplemental.small` property in your variants to provide a compact layout optimized for watch and car surfaces: +Use the `supplementalActivityFamilies.small` property in your variants to provide a compact layout optimized for watch and car surfaces: ```tsx import { useLiveActivity } from 'voltra/client' @@ -47,9 +47,7 @@ function DeliveryActivity({ orderId, eta }) { // Full lock screen UI lockScreen: ( - - Order #{orderId} - + Order #{orderId} Driver en route - ETA {eta} min @@ -66,16 +64,12 @@ function DeliveryActivity({ orderId, eta }) { minimal: , }, - // Supplemental family for watchOS Smart Stack (iOS 18+) - supplemental: { + // Supplemental activity families for watchOS Smart Stack (iOS 18+) + supplementalActivityFamilies: { small: ( - - {eta} min - - - En route - + {eta} min + En route ), }, @@ -91,16 +85,16 @@ function DeliveryActivity({ orderId, eta }) { ## Fallback behavior -If you enable `supplementalFamilies: ["small"]` in your plugin config but don't provide a `supplemental.small` variant in your `useLiveActivity` call, the system will automatically fall back to using your `lockScreen` content. +If you enable `supplementalActivityFamilies: ["small"]` in your plugin config but don't provide a `supplementalActivityFamilies.small` variant in your `useLiveActivity` call, the system will automatically fall back to using your `lockScreen` content. This allows you to: 1. Enable the capability once in your plugin config -2. Gradually add `supplemental.small` variants only where needed +2. Gradually add `supplementalActivityFamilies.small` variants only where needed ## Design guidelines -When designing for `supplemental.small`, keep these guidelines in mind: +When designing for `supplementalActivityFamilies.small`, keep these guidelines in mind: ### Keep it minimal @@ -108,25 +102,21 @@ Watch surfaces have very limited space. Show only the most essential information ```tsx // Good: Essential information only -supplemental: { - small: ( - - 12 min - ETA - - ) +supplementalActivityFamilies: { + small: + 12 min + ETA + } // Avoid: Too much detail for watch display -supplemental: { - small: ( - - Order #12345 - Driver: John Smith - ETA: 12 minutes - Distance: 2.3 miles - - ) +supplementalActivityFamilies: { + small: + Order #12345 + Driver: John Smith + ETA: 12 minutes + Distance: 2.3 miles + } ``` @@ -135,11 +125,13 @@ supplemental: { Smaller fonts are hard to read at a glance on a watch. Use larger font sizes and bold weights for key information. ```tsx - + 25:42 ``` @@ -150,24 +142,24 @@ Ensure your content is readable in various lighting conditions by using high-con ### No interactive elements -Unlike lock screen, supplemental views are for quick glances only. Avoid buttons or toggles - users cannot interact with them on the watch. +Unlike lock screen, supplemental activity families views are for quick glances only. Avoid buttons or toggles - users cannot interact with them on the watch. ## iOS version requirements -| Feature | Minimum iOS Version | -| ------- | ------------------- | -| watchOS Smart Stack | iOS 18.0 | -| CarPlay Dashboard | iOS 26.0 (future) | +| Feature | Minimum iOS Version | +| ------------------- | ------------------- | +| watchOS Smart Stack | iOS 18.0 | +| CarPlay Dashboard | iOS 26.0 | -Live Activities on devices running iOS 16.2-17.x will continue to work normally on the iPhone lock screen and Dynamic Island. The supplemental content is simply ignored on older versions. +Live Activities on devices running iOS 16.2-17.x will continue to work normally on the iPhone lock screen and Dynamic Island. The supplemental activity families content is simply ignored on older versions. ## How it works -When you configure `supplementalFamilies`, the Voltra plugin generates a widget wrapper that applies Apple's `.supplementalActivityFamilies()` modifier with an iOS 18 availability check: +When you configure `supplementalActivityFamilies`, the Voltra plugin generates a widget wrapper that applies Apple's `.supplementalActivityFamilies()` modifier with an iOS 18 availability check: ```swift // Generated by Voltra config plugin -struct VoltraWidgetWithSupplementalFamilies: Widget { +struct VoltraWidgetWithSupplementalActivityFamilies: Widget { private let wrapped = VoltraWidget() var body: some WidgetConfiguration { @@ -188,11 +180,7 @@ The Swift side uses the `@Environment(\.activityFamily)` property to detect whet ```typescript interface LiveActivityConfig { - /** - * Supplemental activity families to enable (iOS 18+) - * Currently only "small" is available - */ - supplementalFamilies?: ('small')[] + supplementalActivityFamilies?: 'small'[] } ``` @@ -205,7 +193,7 @@ interface LiveActivityVariants { /** * Supplemental families for iOS 18+ (watchOS Smart Stack, CarPlay) */ - supplemental?: { + supplementalActivityFamilies?: { /** * Compact view for watchOS Smart Stack and CarPlay * Falls back to lockScreen content if not provided @@ -219,5 +207,5 @@ interface LiveActivityVariants { - [Developing Live Activities](/development/developing-live-activities) - General Live Activity development - [Plugin Configuration](/api/plugin-configuration) - Full plugin configuration reference -- [Apple: Configuring supplemental activity families](https://developer.apple.com/documentation/activitykit/activityconfiguration/supplementalactivityfamilies(_:)) +- [Apple: Configuring supplemental activity families]() - [WWDC24: What's new in Live Activities](https://developer.apple.com/videos/play/wwdc2024/10068/) From 3fec03b94ce988cd5a8a609fd186924d3a86918f Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 13:18:00 +0700 Subject: [PATCH 14/18] refactor: rename --- example/app.json | 2 +- .../live-activities/LiveActivitiesScreen.tsx | 2 +- .../live-activities/WatchLiveActivity.tsx | 2 +- .../__tests__/variants.node.test.tsx | 34 +++++++++---------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/example/app.json b/example/app.json index 84baf88..e563ef7 100644 --- a/example/app.json +++ b/example/app.json @@ -25,7 +25,7 @@ "groupIdentifier": "group.callstackincubator.voltraexample", "enablePushNotifications": true, "liveActivity": { - "supplementalFamilies": ["small"] + "supplementalActivityFamilies": ["small"] }, "widgets": [ { diff --git a/example/screens/live-activities/LiveActivitiesScreen.tsx b/example/screens/live-activities/LiveActivitiesScreen.tsx index ffb6711..2fb0c2b 100644 --- a/example/screens/live-activities/LiveActivitiesScreen.tsx +++ b/example/screens/live-activities/LiveActivitiesScreen.tsx @@ -46,7 +46,7 @@ const ACTIVITY_METADATA: Record, }, - supplemental: { + supplementalActivityFamilies: { small: , }, island: { diff --git a/src/live-activity/__tests__/variants.node.test.tsx b/src/live-activity/__tests__/variants.node.test.tsx index e09cab6..4d7b597 100644 --- a/src/live-activity/__tests__/variants.node.test.tsx +++ b/src/live-activity/__tests__/variants.node.test.tsx @@ -34,22 +34,22 @@ describe('Variants', () => { }) describe('Supplemental Activity Families (iOS 18+)', () => { - test('supplemental.small renders to sup_sm key', async () => { + test('supplementalActivityFamilies.small renders to saf_sm key', async () => { const result = await renderLiveActivityToJson({ lockScreen: Lock Screen, - supplemental: { + supplementalActivityFamilies: { small: Watch, }, }) expect(result).toHaveProperty('ls') - expect(result).toHaveProperty('sup_sm') + expect(result).toHaveProperty('saf_sm') }) - test('supplemental.small content is rendered correctly', async () => { + test('supplementalActivityFamilies.small content is rendered correctly', async () => { const result = await renderLiveActivityToJson({ lockScreen: Lock, - supplemental: { + supplementalActivityFamilies: { small: ( Watch Content @@ -58,13 +58,13 @@ describe('Supplemental Activity Families (iOS 18+)', () => { }, }) - expect(result.sup_sm).toBeDefined() - expect(result.sup_sm.t).toBe(11) - expect(result.sup_sm.c.t).toBe(0) - expect(result.sup_sm.c.c).toBe('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('supplemental families work with all other variants', async () => { + test('supplementalActivityFamilies families work with all other variants', async () => { const result = await renderLiveActivityToJson({ lockScreen: Lock, island: { @@ -77,7 +77,7 @@ describe('Supplemental Activity Families (iOS 18+)', () => { }, minimal: Min, }, - supplemental: { + supplementalActivityFamilies: { small: Watch, }, }) @@ -87,25 +87,25 @@ describe('Supplemental Activity Families (iOS 18+)', () => { expect(result).toHaveProperty('isl_cmp_l') expect(result).toHaveProperty('isl_cmp_t') expect(result).toHaveProperty('isl_min') - expect(result).toHaveProperty('sup_sm') + expect(result).toHaveProperty('saf_sm') }) - test('omitting supplemental.small does not add sup_sm key', async () => { + 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('sup_sm') + expect(result).not.toHaveProperty('saf_sm') }) - test('empty supplemental object does not add sup_sm key', async () => { + test('empty supplementalActivityFamilies object does not add saf_sm key', async () => { const result = await renderLiveActivityToJson({ lockScreen: Lock, - supplemental: {}, + supplementalActivityFamilies: {}, }) expect(result).toHaveProperty('ls') - expect(result).not.toHaveProperty('sup_sm') + expect(result).not.toHaveProperty('saf_sm') }) }) From 66d2a181bd54489c35dbc67c4979c8755653323f Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 13:25:03 +0700 Subject: [PATCH 15/18] fix: restore accidentally removed comments --- ios/shared/VoltraRegion.swift | 1 + plugin/src/features/ios/files/index.ts | 5 +++++ plugin/src/features/ios/files/swift/index.ts | 3 +++ plugin/src/features/ios/files/swift/widgetBundle.ts | 8 +++++++- plugin/src/features/ios/index.ts | 1 + 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/ios/shared/VoltraRegion.swift b/ios/shared/VoltraRegion.swift index b447c0d..d799741 100644 --- a/ios/shared/VoltraRegion.swift +++ b/ios/shared/VoltraRegion.swift @@ -11,6 +11,7 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable { case islandMinimal case supplementalActivityFamiliesSmall + /// The JSON key for this region in the payload public var jsonKey: String { switch self { case .lockScreen: diff --git a/plugin/src/features/ios/files/index.ts b/plugin/src/features/ios/files/index.ts index 4c13b50..ca967c9 100644 --- a/plugin/src/features/ios/files/index.ts +++ b/plugin/src/features/ios/files/index.ts @@ -36,14 +36,18 @@ export const generateWidgetExtensionFiles: ConfigPlugin { const { targetPath, projectRoot, widgets, supplementalActivityFamilies } = options + // Prerender widget initial states if any widgets have initialStatePath configured const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot) + // Generate the initial states Swift file const initialStatesContent = generateInitialStatesSwift(prerenderedStates) const initialStatesPath = path.join(targetPath, 'VoltraWidgetInitialStates.swift') fs.writeFileSync(initialStatesPath, initialStatesContent) logger.info(`Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} pre-rendered widget states`) + // Generate the widget bundle Swift file const widgetBundleContent = widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets, supplementalActivityFamilies) diff --git a/plugin/src/features/ios/files/swift/widgetBundle.ts b/plugin/src/features/ios/files/swift/widgetBundle.ts index bc15ea3..cde6e97 100644 --- a/plugin/src/features/ios/files/swift/widgetBundle.ts +++ b/plugin/src/features/ios/files/swift/widgetBundle.ts @@ -70,7 +70,10 @@ 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()' @@ -91,8 +94,10 @@ export function generateWidgetBundleSwift( @main struct VoltraWidgetBundle: WidgetBundle { var body: some Widget { + // Live Activity Widget (Dynamic Island + Lock Screen) ${liveActivityWidget} + // Home Screen Widgets ${widgetInstances} } } @@ -122,11 +127,12 @@ export function generateDefaultWidgetBundleSwift(supplementalActivityFamilies?: import SwiftUI import WidgetKit - import VoltraWidget + import VoltraWidget // Import Voltra widgets @main struct VoltraWidgetBundle: WidgetBundle { var body: some Widget { + // Live Activity Widget (Dynamic Island + Lock Screen) ${liveActivityWidget} } }${wrapperStruct} diff --git a/plugin/src/features/ios/index.ts b/plugin/src/features/ios/index.ts index 36f2253..15a9548 100644 --- a/plugin/src/features/ios/index.ts +++ b/plugin/src/features/ios/index.ts @@ -30,6 +30,7 @@ export const withIOS: ConfigPlugin = (config, 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, liveActivity }], // 2. Configure Xcode project (must run after files are generated) From 7cd3db90dbbfd52cd277fe10925a2fc2103f4c36 Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 16:51:54 +0700 Subject: [PATCH 16/18] refactor: wording --- plugin/src/types/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/types/plugin.ts b/plugin/src/types/plugin.ts index 9b0ecd4..24b058b 100644 --- a/plugin/src/types/plugin.ts +++ b/plugin/src/types/plugin.ts @@ -26,7 +26,7 @@ export interface ConfigPluginProps { */ deploymentTarget?: string /** - * Configuration for Live Activities (iOS 18+ features) + * Configuration for Live Activities */ liveActivity?: LiveActivityConfig } From 0c054cc15ec138436129320805023736ff434dfd Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 16:53:30 +0700 Subject: [PATCH 17/18] refactor: wording --- ios/target/VoltraWidget.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/target/VoltraWidget.swift b/ios/target/VoltraWidget.swift index b53f059..9cbcf9b 100644 --- a/ios/target/VoltraWidget.swift +++ b/ios/target/VoltraWidget.swift @@ -15,16 +15,16 @@ public struct VoltraWidget: Widget { public var body: some WidgetConfiguration { if #available(iOS 18.0, *) { - return ios18Configuration() + return withAdaptiveViewConfig() } else { - return legacyConfiguration() + return defaultViewConfig() } } // MARK: - iOS 18+ Configuration (with adaptive view for supplemental activity families) @available(iOS 18.0, *) - private func ios18Configuration() -> some WidgetConfiguration { + private func withAdaptiveViewConfig() -> some WidgetConfiguration { ActivityConfiguration(for: VoltraAttributes.self) { context in VoltraAdaptiveLockScreenView( context: context, @@ -42,9 +42,9 @@ public struct VoltraWidget: Widget { // wrapper when configured via plugin (see VoltraWidgetBundle.swift) } - // MARK: - Legacy Configuration (iOS 16.2 - 17.x) + // MARK: - Default Configuration (iOS 16.2 - 17.x) - private func legacyConfiguration() -> some WidgetConfiguration { + 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)) From 84e9362ebfe3ffbdfe3346a8efe62d24544a3bbd Mon Sep 17 00:00:00 2001 From: mrevanzak Date: Thu, 15 Jan 2026 17:39:08 +0700 Subject: [PATCH 18/18] docs: no need to dive into technical on web docs --- .../supplemental-activity-families.md | 211 ------------------ 1 file changed, 211 deletions(-) delete mode 100644 website/docs/development/supplemental-activity-families.md diff --git a/website/docs/development/supplemental-activity-families.md b/website/docs/development/supplemental-activity-families.md deleted file mode 100644 index 3ca8d75..0000000 --- a/website/docs/development/supplemental-activity-families.md +++ /dev/null @@ -1,211 +0,0 @@ -# Supplemental Activity Families - -Starting with iOS 18, Live Activities can appear on additional surfaces beyond the iPhone lock screen and Dynamic Island: - -- **watchOS Smart Stack** (iOS 18+) - Appears on paired Apple Watch -- **CarPlay Dashboard** (iOS 26+) - Appears on CarPlay displays - -Voltra supports the `.small` supplemental activity family, which enables your Live Activities to appear on these new surfaces. - -## Enabling supplemental activity families - -To enable supplemental activity families, add the `liveActivity` configuration to your plugin: - -```json -{ - "expo": { - "plugins": [ - [ - "voltra", - { - "groupIdentifier": "group.com.example", - "liveActivity": { - "supplementalActivityFamilies": ["small"] - } - } - ] - ] - } -} -``` - -:::warning -This is an opt-in feature. Only include `"small"` if you want your Live Activities to appear on watchOS Smart Stack. -::: - -## Providing supplemental activity families content - -Use the `supplementalActivityFamilies.small` property in your variants to provide a compact layout optimized for watch and car surfaces: - -```tsx -import { useLiveActivity } from 'voltra/client' -import { Voltra } from 'voltra' - -function DeliveryActivity({ orderId, eta }) { - const { start, update, end } = useLiveActivity( - { - // Full lock screen UI - lockScreen: ( - - Order #{orderId} - - Driver en route - ETA {eta} min - - {/* Full map or detailed progress would go here */} - - ), - - // Dynamic Island variants - island: { - compact: { - leading: , - trailing: {eta} min, - }, - minimal: , - }, - - // Supplemental activity families for watchOS Smart Stack (iOS 18+) - supplementalActivityFamilies: { - small: ( - - {eta} min - En route - - ), - }, - }, - { - activityName: `delivery-${orderId}`, - } - ) - - return null -} -``` - -## Fallback behavior - -If you enable `supplementalActivityFamilies: ["small"]` in your plugin config but don't provide a `supplementalActivityFamilies.small` variant in your `useLiveActivity` call, the system will automatically fall back to using your `lockScreen` content. - -This allows you to: - -1. Enable the capability once in your plugin config -2. Gradually add `supplementalActivityFamilies.small` variants only where needed - -## Design guidelines - -When designing for `supplementalActivityFamilies.small`, keep these guidelines in mind: - -### Keep it minimal - -Watch surfaces have very limited space. Show only the most essential information - typically 1-2 key metrics. - -```tsx -// Good: Essential information only -supplementalActivityFamilies: { - small: - 12 min - ETA - -} - -// Avoid: Too much detail for watch display -supplementalActivityFamilies: { - small: - Order #12345 - Driver: John Smith - ETA: 12 minutes - Distance: 2.3 miles - -} -``` - -### Use large, legible text - -Smaller fonts are hard to read at a glance on a watch. Use larger font sizes and bold weights for key information. - -```tsx - - 25:42 - -``` - -### High contrast - -Ensure your content is readable in various lighting conditions by using high-contrast color combinations. - -### No interactive elements - -Unlike lock screen, supplemental activity families views are for quick glances only. Avoid buttons or toggles - users cannot interact with them on the watch. - -## iOS version requirements - -| Feature | Minimum iOS Version | -| ------------------- | ------------------- | -| watchOS Smart Stack | iOS 18.0 | -| CarPlay Dashboard | iOS 26.0 | - -Live Activities on devices running iOS 16.2-17.x will continue to work normally on the iPhone lock screen and Dynamic Island. The supplemental activity families content is simply ignored on older versions. - -## How it works - -When you configure `supplementalActivityFamilies`, the Voltra plugin generates a widget wrapper that applies Apple's `.supplementalActivityFamilies()` modifier with an iOS 18 availability check: - -```swift -// Generated by Voltra config plugin -struct VoltraWidgetWithSupplementalActivityFamilies: Widget { - private let wrapped = VoltraWidget() - - var body: some WidgetConfiguration { - if #available(iOS 18.0, *) { - return wrapped.body.supplementalActivityFamilies([.small]) - } else { - return wrapped.body - } - } -} -``` - -The Swift side uses the `@Environment(\.activityFamily)` property to detect whether content is being displayed on a `.small` (watchOS/CarPlay) or `.medium` (iPhone lock screen) surface, automatically choosing the appropriate content. - -## API reference - -### Plugin configuration - -```typescript -interface LiveActivityConfig { - supplementalActivityFamilies?: 'small'[] -} -``` - -### TypeScript variants - -```typescript -interface LiveActivityVariants { - lockScreen: ReactNode | LockScreenConfig - island?: DynamicIslandConfig - /** - * Supplemental families for iOS 18+ (watchOS Smart Stack, CarPlay) - */ - supplementalActivityFamilies?: { - /** - * Compact view for watchOS Smart Stack and CarPlay - * Falls back to lockScreen content if not provided - */ - small?: ReactNode - } -} -``` - -## Related resources - -- [Developing Live Activities](/development/developing-live-activities) - General Live Activity development -- [Plugin Configuration](/api/plugin-configuration) - Full plugin configuration reference -- [Apple: Configuring supplemental activity families]() -- [WWDC24: What's new in Live Activities](https://developer.apple.com/videos/play/wwdc2024/10068/)