Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
8e5f42d
Merge branch 'main' into feat/activity-family
mrevanzak Jan 22, 2026
1b62a51
refactor: support watch-variants at runtime
V3RON Jan 26, 2026
a98423e
refactor: ignore .medium
V3RON Jan 26, 2026
c33538b
refactor: minor tweaks
V3RON Jan 26, 2026
53e8cc5
refactor: remove leftovers
V3RON Jan 26, 2026
75b984e
fix: events
V3RON Jan 26, 2026
8bf8ab3
style: reformat
V3RON Jan 26, 2026
9556bca
style: format
V3RON Jan 27, 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
51 changes: 51 additions & 0 deletions example/components/live-activities/SupplementalFamiliesUI.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react'
import { Voltra } from 'voltra'

export function SupplementalFamiliesLockScreen() {
return (
<Voltra.VStack id="supplemental-families-lock-screen" spacing={12} style={{ padding: 16 }}>
<Voltra.Text
style={{
color: '#F0F9FF',
fontSize: 24,
fontWeight: '700',
}}
>
Lock Screen
</Voltra.Text>
</Voltra.VStack>
)
}

export function SupplementalFamiliesSmall() {
return (
<Voltra.VStack id="supplemental-families-small" spacing={4} alignment="center" style={{ padding: 8 }}>
<Voltra.Text style={{ color: '#3B82F6', fontSize: 16, fontWeight: '700' }}>Watch</Voltra.Text>
<Voltra.Text style={{ color: '#94A3B8', fontSize: 11 }}>watchOS / CarPlay</Voltra.Text>
</Voltra.VStack>
)
}

export function SupplementalFamiliesCompactLeading() {
return (
<Voltra.VStack id="supplemental-families-compact-leading" alignment="center" style={{ padding: 6 }}>
<Voltra.Text style={{ color: '#10B981', fontSize: 14, fontWeight: '700' }}>L</Voltra.Text>
</Voltra.VStack>
)
}

export function SupplementalFamiliesCompactTrailing() {
return (
<Voltra.VStack id="supplemental-families-compact-trailing" alignment="center" style={{ padding: 6 }}>
<Voltra.Text style={{ color: '#10B981', fontSize: 14, fontWeight: '700' }}>R</Voltra.Text>
</Voltra.VStack>
)
}

export function SupplementalFamiliesMinimal() {
return (
<Voltra.VStack id="supplemental-families-minimal" alignment="center" style={{ padding: 6 }}>
<Voltra.Symbol name="checkmark.circle.fill" tintColor="#10B981" />
</Voltra.VStack>
)
}
29 changes: 27 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 SupplementalFamiliesLiveActivity from '~/screens/live-activities/SupplementalFamiliesLiveActivity'
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' | 'supplementalFamilies'

const ACTIVITY_METADATA: Record<ActivityKey, { title: string; description: string }> = {
basic: {
Expand All @@ -43,9 +44,22 @@ const ACTIVITY_METADATA: Record<ActivityKey, { title: string; description: strin
title: 'Compass',
description: 'Real-time compass using magnetometer with rotating arrow indicator.',
},
supplementalFamilies: {
title: 'Supplemental Families (iOS 18+)',
description:
'Demonstrates supplemental activity families: small (Watch/CarPlay) with compact Dynamic Island fallback. StandBy displays lock screen.',
},
}

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

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

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

const activityRefs = useMemo(
() => ({
Expand All @@ -74,6 +90,7 @@ export default function LiveActivitiesScreen() {
flight: flightRef,
workout: workoutRef,
compass: compassRef,
supplementalFamilies: supplementalFamiliesRef,
}),
[]
)
Expand Down Expand Up @@ -109,6 +126,10 @@ export default function LiveActivitiesScreen() {
(isActive: boolean) => handleStatusChange('compass', isActive),
[handleStatusChange]
)
const handleSupplementalFamiliesStatusChange = useCallback(
(isActive: boolean) => handleStatusChange('supplementalFamilies', isActive),
[handleStatusChange]
)

const handleStart = async (key: ActivityKey) => {
await activityRefs[key].current?.start?.().catch(console.error)
Expand Down Expand Up @@ -187,6 +208,10 @@ export default function LiveActivitiesScreen() {
<FlightLiveActivity ref={flightRef} onIsActiveChange={handleFlightStatusChange} />
<WorkoutLiveActivity ref={workoutRef} onIsActiveChange={handleWorkoutStatusChange} />
<CompassLiveActivity ref={compassRef} onIsActiveChange={handleCompassStatusChange} />
<SupplementalFamiliesLiveActivity
ref={supplementalFamiliesRef}
onIsActiveChange={handleSupplementalFamiliesStatusChange}
/>
</ScrollView>
</View>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'
import { useLiveActivity } from 'voltra/client'

import {
SupplementalFamiliesCompactLeading,
SupplementalFamiliesCompactTrailing,
SupplementalFamiliesLockScreen,
SupplementalFamiliesMinimal,
SupplementalFamiliesSmall,
} from '../../components/live-activities/SupplementalFamiliesUI'
import { LiveActivityExampleComponent } from './types'

const SupplementalFamiliesLiveActivity: LiveActivityExampleComponent = forwardRef(
({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => {
const { start, update, end, isActive } = useLiveActivity(
{
lockScreen: {
content: <SupplementalFamiliesLockScreen />,
},
supplementalActivityFamilies: {
small: <SupplementalFamiliesSmall />,
},
island: {
keylineTint: '#10B981',
compact: {
leading: <SupplementalFamiliesCompactLeading />,
trailing: <SupplementalFamiliesCompactTrailing />,
},
minimal: <SupplementalFamiliesMinimal />,
},
},
{
activityName: 'supplemental-families-demo',
autoUpdate,
autoStart,
deepLinkUrl: '/voltraui/supplemental-families-demo',
}
)

useEffect(() => {
onIsActiveChange?.(isActive)
}, [isActive, onIsActiveChange])

useImperativeHandle(ref, () => ({
start,
update,
end,
}))

return null
}
)

SupplementalFamiliesLiveActivity.displayName = 'SupplementalFamiliesLiveActivity'

export default SupplementalFamiliesLiveActivity
5 changes: 5 additions & 0 deletions ios/app/VoltraActivity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ActivityKit
import Foundation

/// Type alias for Live Activity with unified attributes
public typealias VoltraActivity = Activity<VoltraAttributes>
110 changes: 103 additions & 7 deletions ios/app/VoltraLiveActivityService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,14 @@ public class VoltraLiveActivityService {
return Array(Activity<VoltraAttributes>.activities)
}

/// Get the latest (most recently created) activity
public func getLatestActivity() -> Activity<VoltraAttributes>? {
/// Get the latest (most recently created) activity across both types
public func getLatestActivity() -> VoltraActivity? {
guard Self.isSupported() else { return nil }
return Activity<VoltraAttributes>.activities.last
let allActivities = getAllActivities()
return allActivities.last
}

/// Check if an activity with the given name exists
/// Check if an activity with the given name exists across both types
public func isActivityActive(name: String) -> Bool {
findActivity(byName: name) != nil
}
Expand Down Expand Up @@ -153,7 +154,7 @@ public class VoltraLiveActivityService {
let initialState = try VoltraAttributes.ContentState(uiJsonData: request.jsonString)

// Request the activity
let activity = try Activity.request(
_ = try Activity.request(
attributes: attributes,
content: .init(
state: initialState,
Expand All @@ -174,7 +175,7 @@ public class VoltraLiveActivityService {
/// - request: Parameters for updating the activity
/// - Throws: Error if update fails
public func updateActivity(
_ activity: Activity<VoltraAttributes>,
_ activity: VoltraActivity,
request: UpdateActivityRequest
) async throws {
guard Self.isSupported() else {
Expand Down Expand Up @@ -212,7 +213,7 @@ public class VoltraLiveActivityService {
/// - Parameter activity: The activity to end
/// - Parameter dismissalPolicy: How the activity should be dismissed
public func endActivity(
_ activity: Activity<VoltraAttributes>,
_ activity: VoltraActivity,
dismissalPolicy: ActivityUIDismissalPolicy = .immediate
) async {
guard Self.isSupported() else { return }
Expand Down Expand Up @@ -254,6 +255,101 @@ public class VoltraLiveActivityService {
await endActivity(activity)
}
}

// MARK: - Monitoring

private var monitoredActivityIds: Set<String> = []
private var monitoringTasks: [Task<Void, Never>] = []

/// Start monitoring all Live Activities and Push Tokens
public func startMonitoring(enablePush: Bool) {
guard Self.isSupported() else { return }

// 1. Monitor Push-to-Start Tokens
if enablePush {
if #available(iOS 17.2, *) {
startPushToStartTokenObservation()
}
}

// 2. Monitor Live Activity Updates (Creation & Lifecycle)
startActivityUpdatesObservation(enablePush: enablePush)
}

/// Stop all monitoring tasks
public func stopMonitoring() {
monitoredActivityIds.removeAll()
monitoringTasks.forEach { $0.cancel() }
monitoringTasks.removeAll()
}

@available(iOS 17.2, *)
private func startPushToStartTokenObservation() {
if let initialTokenData = Activity<VoltraAttributes>.pushToStartToken {
let token = initialTokenData.hexString
VoltraEventBus.shared.send(.pushToStartTokenReceived(token: token))
}
let task = Task {
for await tokenData in Activity<VoltraAttributes>.pushToStartTokenUpdates {
let token = tokenData.hexString
VoltraEventBus.shared.send(.pushToStartTokenReceived(token: token))
}
}
monitoringTasks.append(task)
}

private func startActivityUpdatesObservation(enablePush: Bool) {
// 1. Handle currently existing activities
for activity in Activity<VoltraAttributes>.activities {
monitorActivity(activity, enablePush: enablePush)
}

// 2. Listen for NEW activities
let updatesTask = Task {
for await newActivity in Activity<VoltraAttributes>.activityUpdates {
monitorActivity(newActivity, enablePush: enablePush)
}
}
monitoringTasks.append(updatesTask)
}

/// Set up observers for an activity's lifecycle
private func monitorActivity(_ activity: Activity<VoltraAttributes>, enablePush: Bool) {
let activityId = activity.id

// Avoid duplicate monitoring
guard !monitoredActivityIds.contains(activityId) else { return }
monitoredActivityIds.insert(activityId)

// Lifecycle state changes
let stateTask = Task {
for await state in activity.activityStateUpdates {
VoltraEventBus.shared.send(
.stateChange(
activityName: activity.attributes.name,
state: String(describing: state)
)
)
}
}
monitoringTasks.append(stateTask)

// Push token updates
if enablePush {
let tokenTask = Task {
for await pushTokenData in activity.pushTokenUpdates {
let pushTokenString = pushTokenData.hexString
VoltraEventBus.shared.send(
.tokenReceived(
activityName: activity.attributes.name,
pushToken: pushTokenString
)
)
}
}
monitoringTasks.append(tokenTask)
}
}
}

// MARK: - Errors
Expand Down
Loading
Loading