Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2fcfa62
feat(types): add supplemental activity families to LiveActivityVariants
mrevanzak Jan 14, 2026
550a48d
feat(renderer): add supplemental family region rendering
mrevanzak Jan 14, 2026
4f92fd8
feat(swift): add supplemental family cases to VoltraRegion
mrevanzak Jan 14, 2026
8cf3bd7
feat(swift): add iOS 18 supplementalActivityFamilies support with ada…
mrevanzak Jan 14, 2026
0b961ce
feat(plugin): add ActivityFamily types for supplemental families config
mrevanzak Jan 14, 2026
9b1c236
feat(plugin): add activity family constants and Swift mapping
mrevanzak Jan 14, 2026
6f962ff
feat(plugin): add validation for supplemental activity families
mrevanzak Jan 14, 2026
678dce5
feat(plugin): generate supplementalActivityFamilies in widget bundle
mrevanzak Jan 14, 2026
0fc1175
feat(plugin): wire up liveActivity config to iOS generation
mrevanzak Jan 14, 2026
ed1ae53
test: add tests for supplemental families and example Watch demo
mrevanzak Jan 14, 2026
696414e
fix(swift): separate @unknown default into own case in switch statement
mrevanzak Jan 14, 2026
80c8428
docs: add supplemental activity families documentation for website
mrevanzak Jan 14, 2026
1ac4a0a
refactor: rename supplementalFamilies to supplementalActivityFamilies
mrevanzak Jan 14, 2026
3fec03b
refactor: rename
mrevanzak Jan 15, 2026
66d2a18
fix: restore accidentally removed comments
mrevanzak Jan 15, 2026
7cd3db9
refactor: wording
mrevanzak Jan 15, 2026
0c054cc
refactor: wording
mrevanzak Jan 15, 2026
84e9362
docs: no need to dive into technical on web docs
mrevanzak Jan 15, 2026
432aa5a
Merge branch 'main' into feat/activity-family
mrevanzak Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
{
"groupIdentifier": "group.callstackincubator.voltraexample",
"enablePushNotifications": true,
"liveActivity": {
"supplementalActivityFamilies": ["small"]
},
"widgets": [
{
"id": "weather",
Expand Down
59 changes: 59 additions & 0 deletions example/components/live-activities/WatchLiveActivityUI.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react'
import { Voltra } from 'voltra'

export function WatchLiveActivityLockScreen() {
return (
<Voltra.VStack id="watch-lock-screen" spacing={12} style={{ padding: 16 }}>
<Voltra.HStack spacing={12} alignment="center">
<Voltra.Image
source={{ assetName: 'voltra-icon' }}
style={{ width: 48, height: 48, borderRadius: 10 }}
resizeMode="stretch"
/>
<Voltra.VStack spacing={4} alignment="leading">
<Voltra.Text
style={{
color: '#F0F9FF',
fontSize: 20,
fontWeight: '700',
}}
>
Workout Active
</Voltra.Text>
<Voltra.Text
style={{
color: '#94A3B8',
fontSize: 14,
}}
>
Running · 3.2 km
</Voltra.Text>
</Voltra.VStack>
</Voltra.HStack>

<Voltra.HStack distribution="equalSpacing">
<Voltra.VStack alignment="center">
<Voltra.Text style={{ color: '#10B981', fontSize: 24, fontWeight: '700' }}>25:42</Voltra.Text>
<Voltra.Text style={{ color: '#64748B', fontSize: 12 }}>Duration</Voltra.Text>
</Voltra.VStack>
<Voltra.VStack alignment="center">
<Voltra.Text style={{ color: '#F59E0B', fontSize: 24, fontWeight: '700' }}>142</Voltra.Text>
<Voltra.Text style={{ color: '#64748B', fontSize: 12 }}>BPM</Voltra.Text>
</Voltra.VStack>
<Voltra.VStack alignment="center">
<Voltra.Text style={{ color: '#8B5CF6', fontSize: 24, fontWeight: '700' }}>312</Voltra.Text>
<Voltra.Text style={{ color: '#64748B', fontSize: 12 }}>Calories</Voltra.Text>
</Voltra.VStack>
</Voltra.HStack>
</Voltra.VStack>
)
}

export function WatchLiveActivitySmall() {
return (
<Voltra.VStack id="watch-small" spacing={4} alignment="center" style={{ padding: 8 }}>
<Voltra.Text style={{ color: '#10B981', fontSize: 18, fontWeight: '700' }}>25:42</Voltra.Text>
<Voltra.Text style={{ color: '#F59E0B', fontSize: 14, fontWeight: '600' }}>142 BPM</Voltra.Text>
</Voltra.VStack>
)
}
17 changes: 15 additions & 2 deletions example/screens/live-activities/LiveActivitiesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityKey, { title: string; description: string }> = {
basic: {
Expand All @@ -43,9 +44,13 @@ const ACTIVITY_METADATA: Record<ActivityKey, { title: string; description: strin
title: 'Compass',
description: 'Real-time compass using magnetometer with rotating arrow indicator.',
},
watch: {
title: 'Watch Demo (iOS 18+)',
description: 'Demonstrates supplementalActivityFamilies.small for watchOS Smart Stack appearance.',
},
}

const CARD_ORDER: ActivityKey[] = ['basic', 'stylesheet', 'glass', 'flight', 'workout', 'compass']
const CARD_ORDER: ActivityKey[] = ['basic', 'stylesheet', 'glass', 'flight', 'workout', 'compass', 'watch']

export default function LiveActivitiesScreen() {
const insets = useSafeAreaInsets()
Expand All @@ -57,6 +62,7 @@ export default function LiveActivitiesScreen() {
flight: false,
workout: false,
compass: false,
watch: false,
})

const basicRef = useRef<LiveActivityExampleComponentRef>(null)
Expand All @@ -65,6 +71,7 @@ export default function LiveActivitiesScreen() {
const flightRef = useRef<LiveActivityExampleComponentRef>(null)
const workoutRef = useRef<LiveActivityExampleComponentRef>(null)
const compassRef = useRef<LiveActivityExampleComponentRef>(null)
const watchRef = useRef<LiveActivityExampleComponentRef>(null)

const activityRefs = useMemo(
() => ({
Expand All @@ -74,6 +81,7 @@ export default function LiveActivitiesScreen() {
flight: flightRef,
workout: workoutRef,
compass: compassRef,
watch: watchRef,
}),
[]
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -187,6 +199,7 @@ export default function LiveActivitiesScreen() {
<FlightLiveActivity ref={flightRef} onIsActiveChange={handleFlightStatusChange} />
<WorkoutLiveActivity ref={workoutRef} onIsActiveChange={handleWorkoutStatusChange} />
<CompassLiveActivity ref={compassRef} onIsActiveChange={handleCompassStatusChange} />
<WatchLiveActivity ref={watchRef} onIsActiveChange={handleWatchStatusChange} />
</ScrollView>
</View>
)
Expand Down
48 changes: 48 additions & 0 deletions example/screens/live-activities/WatchLiveActivity.tsx
Original file line number Diff line number Diff line change
@@ -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: <WatchLiveActivityLockScreen />,
},
supplementalActivityFamilies: {
small: <WatchLiveActivitySmall />,
},
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
3 changes: 3 additions & 0 deletions ios/shared/VoltraRegion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
}
}
128 changes: 97 additions & 31 deletions ios/target/VoltraWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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<VoltraAttributes>) -> 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
Comment on lines +104 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I understand this better. Apple’s docs aren't very clear about what behavior to expect 👌

@available(iOS 18.0, *)
struct VoltraAdaptiveLockScreenView: View {
let context: ActivityViewContext<VoltraAttributes>
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)
}
}
}
13 changes: 13 additions & 0 deletions plugin/src/constants/activities.ts
Original file line number Diff line number Diff line change
@@ -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<ActivityFamily, string> = {
small: '.small',
}
1 change: 1 addition & 0 deletions plugin/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading