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.