# Live Activity Patterns

Complete implementation patterns for ActivityKit Live Activities, Dynamic
Island, push-to-update, and lifecycle management. All patterns use modern
Swift async/await and `ActivityContent`, so they target iOS 16.2+ unless noted.

## Contents

- [Complete ActivityAttributes and ContentState](#complete-activityattributes-and-contentstate)
- [Starting a Live Activity with All Parameters](#starting-a-live-activity-with-all-parameters)
- [Updating from the App](#updating-from-the-app)
- [Push-to-Update Server Payload Format](#push-to-update-server-payload-format)
- [Ending with Different Dismissal Policies](#ending-with-different-dismissal-policies)
- [Complete Dynamic Island Layout (All Regions)](#complete-dynamic-island-layout-all-regions)
- [Lock Screen Layout with Timer and Progress](#lock-screen-layout-with-timer-and-progress)
- [Multiple Concurrent Activities](#multiple-concurrent-activities)
- [Observing Activity State Changes](#observing-activity-state-changes)
- [Token Update Handling](#token-update-handling)
- [Authorization Check](#authorization-check)
- [Error Handling](#error-handling)
- [Background Handling Considerations](#background-handling-considerations)
- [Testing in Simulator and on Device](#testing-in-simulator-and-on-device)
- [Info.plist Keys Reference](#infoplist-keys-reference)
- [Apple Documentation Links](#apple-documentation-links)

## Complete ActivityAttributes and ContentState

Define the data model for your Live Activity. Static properties go on the
outer struct; dynamic properties go in `ContentState`.

```swift
import ActivityKit

struct RideAttributes: ActivityAttributes {
    // Static -- set at creation, immutable for the activity lifetime
    var riderName: String
    var pickupLocation: String
    var dropoffLocation: String

    struct ContentState: Codable, Hashable {
        var driverName: String
        var driverPhoto: String        // SF Symbol name or asset name
        var vehicleDescription: String
        var etaStartSeconds: Int
        var etaEndSeconds: Int
        // For server pushes, prefer scalar fields or coordinated custom Codable keys.
        var status: RideStatus
        var distanceRemaining: Double   // miles
    }
}

enum RideStatus: String, Codable, Hashable {
    case driverAssigned
    case driverEnRoute
    case driverArrived
    case inProgress
    case arriving
    case completed
    case cancelled
    case failed
}

extension RideAttributes.ContentState {
    var etaRange: ClosedRange<Date> {
        Date(timeIntervalSince1970: TimeInterval(etaStartSeconds))...
            Date(timeIntervalSince1970: TimeInterval(etaEndSeconds))
    }
}
```

Keep `ContentState` lightweight. ActivityKit attributes and content-state data
must fit within the framework's 4 KB data limit. Avoid storing images, large
strings, or deeply nested objects.

## Starting a Live Activity with All Parameters

```swift
import ActivityKit

@MainActor
func startRideActivity(
    rider: String,
    pickup: String,
    dropoff: String,
    driver: String,
    vehicle: String
) async throws -> Activity<RideAttributes> {
    // Check authorization before attempting to start
    let authInfo = ActivityAuthorizationInfo()
    guard authInfo.areActivitiesEnabled else {
        throw RideError.liveActivitiesDisabled
    }

    let attributes = RideAttributes(
        riderName: rider,
        pickupLocation: pickup,
        dropoffLocation: dropoff
    )

    let initialState = RideAttributes.ContentState(
        driverName: driver,
        driverPhoto: "car.fill",
        vehicleDescription: vehicle,
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().addingTimeInterval(600).timeIntervalSince1970),
        status: .driverAssigned,
        distanceRemaining: 2.5
    )

    let content = ActivityContent(
        state: initialState,
        staleDate: Date().addingTimeInterval(120), // stale after 2 min
        relevanceScore: 80
    )

    let activity = try Activity.request(
        attributes: attributes,
        content: content,
        pushType: .token  // Enable push updates
    )

    // Forward push token to server for remote updates
    Task {
        for await token in activity.pushTokenUpdates {
            let tokenString = token.map { String(format: "%02x", $0) }.joined()
            try? await ServerAPI.shared.registerActivityToken(
                tokenString, rideID: activity.id
            )
        }
    }

    // Observe state changes for cleanup
    Task {
        for await state in activity.activityStateUpdates {
            if state == .dismissed {
                // Activity removed from UI -- clean up local resources
                RideStore.shared.removeActivity(id: activity.id)
            }
        }
    }

    return activity
}
```

### Starting with Scheduled Date (iOS 26+)

Schedule the activity to appear at a future time without the app in foreground:

```swift
let gameTime = Calendar.current.date(
    from: DateComponents(year: 2026, month: 3, day: 15, hour: 19, minute: 0)
)!

let activity = try Activity.request(
    attributes: attributes,
    content: content,
    pushType: .token,
    style: .standard,
    alertConfiguration: AlertConfiguration(
        title: "Game Starting",
        body: "The live score is ready.",
        sound: .default
    ),
    start: gameTime  // iOS 26+
)
```

### Starting with ActivityStyle (iOS 18+ request parameter)

Use `.standard` for persistent Live Activities that should remain visible until
the app, push, user, or system duration limit ends them. `.transient` is only
for short-lived expanded Dynamic Island presentations that can auto-end when the
user locks the device, collapses or shrinks the expanded presentation, leaves
the app, or does other work outside Dynamic Island; it is wrong for persistent
Live Activities.

```swift
let activity = try Activity.request(
    attributes: attributes,
    content: content,
    pushType: .token,
    style: .standard
)
```

## Updating from the App

```swift
func updateRideActivity(
    _ activity: Activity<RideAttributes>,
    newStatus: RideStatus,
    eta: ClosedRange<Date>,
    distance: Double,
    showAlert: Bool = false
) async {
    let updatedState = RideAttributes.ContentState(
        driverName: activity.content.state.driverName,
        driverPhoto: activity.content.state.driverPhoto,
        vehicleDescription: activity.content.state.vehicleDescription,
        etaStartSeconds: Int(eta.lowerBound.timeIntervalSince1970),
        etaEndSeconds: Int(eta.upperBound.timeIntervalSince1970),
        status: newStatus,
        distanceRemaining: distance
    )

    let content = ActivityContent(
        state: updatedState,
        staleDate: Date().addingTimeInterval(120),
        relevanceScore: newStatus == .driverArrived ? 100 : 80
    )

    if showAlert {
        await activity.update(content, alertConfiguration: AlertConfiguration(
            title: "Ride Update",
            body: alertMessage(for: newStatus),
            sound: .default
        ))
    } else {
        await activity.update(content)
    }
}

private func alertMessage(for status: RideStatus) -> String {
    switch status {
    case .driverArrived: "Your driver has arrived!"
    case .arriving: "You're almost there!"
    case .completed: "You've arrived at your destination."
    default: "Your ride status has changed."
    }
}
```

## Push-to-Update Server Payload Format

### Update Payload

```json
{
    "aps": {
        "timestamp": 1700000000,
        "event": "update",
        "content-state": {
            "driverName": "Maria",
            "driverPhoto": "car.fill",
            "vehicleDescription": "White Toyota Camry",
            "etaStartSeconds": 1700000000,
            "etaEndSeconds": 1700000300,
            "status": "driverArrived",
            "distanceRemaining": 0.0
        },
        "stale-date": 1700000300,
        "relevance-score": 100,
        "alert": {
            "title": "Ride Update",
            "body": "Your driver has arrived!",
            "sound": "default"
        }
    }
}
```

### End Payload

```json
{
    "aps": {
        "timestamp": 1700002000,
        "event": "end",
        "dismissal-date": 1700005600,
        "content-state": {
            "driverName": "Maria",
            "driverPhoto": "car.fill",
            "vehicleDescription": "White Toyota Camry",
            "etaStartSeconds": 1700002000,
            "etaEndSeconds": 1700002000,
            "status": "completed",
            "distanceRemaining": 0.0
        }
    }
}
```

### Push-to-Start Payload (iOS 17.2+)

Send to the push-to-start token to remotely create an activity. The `alert` field is required for push-to-start:

```json
{
    "aps": {
        "timestamp": 1700000000,
        "event": "start",
        "attributes-type": "RideAttributes",
        "attributes": {
            "riderName": "Jordan",
            "pickupLocation": "123 Main St",
            "dropoffLocation": "456 Oak Ave"
        },
        "content-state": {
            "driverName": "Maria",
            "driverPhoto": "car.fill",
            "vehicleDescription": "White Toyota Camry",
            "etaStartSeconds": 1700000000,
            "etaEndSeconds": 1700000600,
            "status": "driverAssigned",
            "distanceRemaining": 3.2
        },
        "alert": {
            "title": "Ride Matched",
            "body": "Maria is on the way in a White Toyota Camry."
        }
    }
}
```

### Required APNs HTTP Headers

| Header | Value |
|---|---|
| `apns-push-type` | `liveactivity` |
| `apns-topic` | `<bundle-id>.push-type.liveactivity` |
| `apns-priority` | `5` (lower priority) or `10` (immediate, counts against budget) |
| `authorization` | `bearer <jwt>` (token auth) or use certificate auth |

The `aps.alert` payload controls visible alert/banner/sound behavior; priority
alone does not create an alert. The `content-state` JSON must decode into
`ActivityAttributes.ContentState`. Use the default synthesized `Codable` key and
value shape unless the Swift model declares custom `CodingKeys`; then coordinate
those exact keys and value shapes server-side. Do not assume `Date` or
`ClosedRange<Date>` values are Unix timestamp dictionaries unless your Swift
model explicitly encodes them that way. A type mismatch (e.g., sending a string
where a number is expected) can prevent ActivityKit from applying the update.

### Channel / Broadcast Updates (iOS 18+)

Use channel-based push only with a valid APNs-created channel ID. Enable the
broadcast capability outside Xcode, have the server create the channel, and pass
that channel ID to the app:

```swift
let activity = try Activity.request(
    attributes: attributes,
    content: content,
    pushType: .channel(channelIDFromServer)
)
```

Channel pushes can update or end Live Activities, but cannot start them. Use
`apns-channel-id` and expiration for channel requests instead of the device-token
`apns-topic` header.

## Ending with Different Dismissal Policies

```swift
func endRideActivity(
    _ activity: Activity<RideAttributes>,
    finalStatus: RideStatus
) async {
    let finalState = RideAttributes.ContentState(
        driverName: activity.content.state.driverName,
        driverPhoto: activity.content.state.driverPhoto,
        vehicleDescription: activity.content.state.vehicleDescription,
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().timeIntervalSince1970),
        status: finalStatus,
        distanceRemaining: 0
    )

    let content = ActivityContent(state: finalState, staleDate: nil, relevanceScore: 0)

    switch finalStatus {
    case .completed:
        // Keep on Lock Screen for 1 hour so user can review trip details
        await activity.end(content, dismissalPolicy: .after(
            Date().addingTimeInterval(3600)
        ))
    case .cancelled:
        // Remove immediately -- no useful info to show
        await activity.end(content, dismissalPolicy: .immediate)
    default:
        // Let the system decide
        await activity.end(content, dismissalPolicy: .default)
    }
}
```

When reviewing duration claims, distinguish the active lifetime (up to 8 hours
unless the app or user ends it sooner), system-ended Lock Screen presence (up to
4 additional hours, for 12 hours total from start), and app-ended `.default`
dismissal linger (up to 4 hours after ending).

### Ending on Terminal Server Failure

When a server reports that the tracked event failed or can no longer be
represented accurately, publish a terminal state and end the activity instead of
leaving stale progress visible.

```swift
func handleTerminalServerFailure(
    _ activity: Activity<RideAttributes>,
    message: String
) async {
    let failedState = RideAttributes.ContentState(
        driverName: activity.content.state.driverName,
        driverPhoto: activity.content.state.driverPhoto,
        vehicleDescription: message,
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().timeIntervalSince1970),
        status: .failed,
        distanceRemaining: 0
    )

    let content = ActivityContent(state: failedState, staleDate: nil, relevanceScore: 0)
    await activity.end(content, dismissalPolicy: .immediate)
}
```

### Ending All Activities (cleanup on sign-out)

```swift
func endAllRideActivities() async {
    for activity in Activity<RideAttributes>.activities {
        await activity.end(nil, dismissalPolicy: .immediate)
    }
}
```

## Complete Dynamic Island Layout (All Regions)

```swift
struct RideActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: RideAttributes.self) { context in
            // Lock Screen presentation
            RideLockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // EXPANDED: shown on long-press
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Image(systemName: context.state.driverPhoto)
                            .font(.title2)
                        Text(context.state.driverName)
                            .font(.caption2)
                            .lineLimit(1)
                    }
                }

                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text(timerInterval: context.state.etaRange, countsDown: true)
                            .font(.title3.monospacedDigit())
                        Text(String(format: "%.1f mi", context.state.distanceRemaining))
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                    }
                }

                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.status.displayName)
                        .font(.headline)
                        .lineLimit(1)
                }

                DynamicIslandExpandedRegion(.bottom) {
                    VStack {
                        ProgressView(
                            value: context.state.status.progress,
                            total: 1.0
                        )
                        .tint(.green)

                        HStack {
                            Label(context.attributes.pickupLocation,
                                  systemImage: "mappin.circle.fill")
                            Spacer()
                            Label(context.attributes.dropoffLocation,
                                  systemImage: "flag.checkered")
                        }
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                    }
                }
            } compactLeading: {
                // COMPACT LEADING: tiny icon identifying the activity
                Image(systemName: context.state.driverPhoto)
                    .foregroundStyle(.green)
            } compactTrailing: {
                // COMPACT TRAILING: one key value
                Text(timerInterval: context.state.etaRange, countsDown: true)
                    .frame(width: 44)
                    .monospacedDigit()
            } minimal: {
                // MINIMAL: shown when multiple activities compete
                Image(systemName: "car.fill")
                    .foregroundStyle(.green)
            }
            .keylineTint(.green)
        }
    }
}
```

## Lock Screen Layout with Timer and Progress

```swift
struct RideLockScreenView: View {
    let context: ActivityViewContext<RideAttributes>

    var body: some View {
        VStack {
            // Header
            HStack {
                VStack(alignment: .leading) {
                    Text(context.state.status.displayName)
                        .font(.headline)
                    Text(context.state.vehicleDescription)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Spacer()
                // Live countdown timer (auto-updating, no code needed)
                Text(timerInterval: context.state.etaRange, countsDown: true)
                    .font(.title2.monospacedDigit().bold())
                    .foregroundStyle(.green)
            }

            if context.isStale {
                Label("Checking for updates...",
                      systemImage: "arrow.trianglehead.2.clockwise")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            // Progress bar
            ProgressView(value: context.state.status.progress, total: 1.0)
                .tint(.green)

            // Route
            HStack {
                VStack(alignment: .leading) {
                    Text("Pickup").font(.caption2).foregroundStyle(.secondary)
                    Text(context.attributes.pickupLocation).font(.caption).lineLimit(1)
                }
                Spacer()
                Image(systemName: "arrow.right")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                VStack(alignment: .trailing) {
                    Text("Dropoff").font(.caption2).foregroundStyle(.secondary)
                    Text(context.attributes.dropoffLocation).font(.caption).lineLimit(1)
                }
            }
        }
        .padding()
    }
}
```

## Multiple Concurrent Activities

An app can run multiple Live Activities simultaneously (system limit applies).
Track them by storing references or querying `Activity<T>.activities`.

```swift
@Observable
@MainActor
final class ActivityManager {
    private(set) var activeDeliveries: [String: Activity<DeliveryAttributes>] = [:]

    func startDelivery(orderID: String, attributes: DeliveryAttributes,
                       state: DeliveryAttributes.ContentState) async throws {
        let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 75)
        let activity = try Activity.request(
            attributes: attributes, content: content, pushType: .token
        )
        activeDeliveries[orderID] = activity

        // Token forwarding
        Task { [weak self] in
            for await token in activity.pushTokenUpdates {
                let tokenString = token.map { String(format: "%02x", $0) }.joined()
                try? await ServerAPI.shared.registerActivityToken(tokenString, orderID: orderID)
            }
            self?.activeDeliveries.removeValue(forKey: orderID)
        }
    }

    func updateDelivery(orderID: String, state: DeliveryAttributes.ContentState) async {
        guard let activity = activeDeliveries[orderID] else { return }
        let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 80)
        await activity.update(content)
    }

    func endDelivery(orderID: String, finalState: DeliveryAttributes.ContentState) async {
        guard let activity = activeDeliveries[orderID] else { return }
        let content = ActivityContent(state: finalState, staleDate: nil, relevanceScore: 0)
        await activity.end(content, dismissalPolicy: .default)
        activeDeliveries.removeValue(forKey: orderID)
    }

    /// Reconcile in-memory state with system activities on app launch
    func reconcile() {
        let systemActivities = Activity<DeliveryAttributes>.activities
        for activity in systemActivities {
            let orderID = "\(activity.attributes.orderNumber)"
            if activeDeliveries[orderID] == nil {
                activeDeliveries[orderID] = activity
            }
        }
    }
}
```

## Observing Activity State Changes

```swift
func observeActivityState(_ activity: Activity<RideAttributes>) {
    // State updates: .active, .pending, .stale, .ended, .dismissed
    Task {
        for await state in activity.activityStateUpdates {
            switch state {
            case .active:
                print("Activity is visible and running")
            case .pending:
                // iOS 26+: scheduled but not yet displayed
                print("Activity is pending start")
            case .stale:
                // iOS 16.2+: staleDate passed without an update
                print("Content is stale -- update or end")
            case .ended:
                // Ended but may still be visible on Lock Screen
                print("Activity ended, may still linger on Lock Screen")
            case .dismissed:
                // Fully removed from UI -- safe to release resources
                print("Activity dismissed from Lock Screen")
                cleanupResources(for: activity.id)
            @unknown default:
                break
            }
        }
    }

    // Content updates (observe state changes from push or other processes)
    Task {
        for await content in activity.contentUpdates {
            print("New state: \(content.state)")
        }
    }
}
```

## Token Update Handling

Push tokens can change at any time. Always observe the async sequence and
re-register with your server.

```swift
func observePushToken(for activity: Activity<RideAttributes>) {
    Task {
        for await token in activity.pushTokenUpdates {
            let tokenString = token.map { String(format: "%02x", $0) }.joined()
            do {
                try await ServerAPI.shared.registerActivityToken(
                    tokenString, activityID: activity.id
                )
            } catch {
                // Retry with exponential backoff; token is critical for updates
                print("Failed to register token: \(error)")
            }
        }
    }
}

/// Observe the ActivityKit push-to-start token for remote activity creation (iOS 17.2+).
/// This token is distinct from ordinary app/device APNs tokens and per-activity update tokens.
func observePushToStartToken() {
    Task {
        for await token in Activity<RideAttributes>.pushToStartTokenUpdates {
            let tokenString = token.map { String(format: "%02x", $0) }.joined()
            try? await ServerAPI.shared.registerPushToStartToken(tokenString)
        }
    }
}
```

## Authorization Check

Always check authorization before starting an activity. The user can disable
Live Activities in Settings at any time.

```swift
func checkLiveActivityAuthorization() async -> Bool {
    let authInfo = ActivityAuthorizationInfo()
    return authInfo.areActivitiesEnabled
}

func checkFrequentPushAuthorization() -> Bool {
    ActivityAuthorizationInfo().frequentPushesEnabled
}

/// Observe authorization changes to react when user toggles the setting
func observeAuthorization() {
    Task {
        let authInfo = ActivityAuthorizationInfo()
        for await enabled in authInfo.activityEnablementUpdates {
            if enabled {
                observePushToStartToken()
            } else {
                try? await ServerAPI.shared.disableActivityPush()
            }
        }
    }

    Task {
        let authInfo = ActivityAuthorizationInfo()
        for await frequentPushesEnabled in authInfo.frequentPushEnablementUpdates {
            try? await ServerAPI.shared.setFrequentPushesEnabled(frequentPushesEnabled)
        }
    }
}
```

## Error Handling

```swift
func startActivitySafely(
    attributes: DeliveryAttributes,
    state: DeliveryAttributes.ContentState
) async {
    let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 75)

    do {
        let activity = try Activity.request(
            attributes: attributes, content: content, pushType: .token
        )
        print("Started: \(activity.id)")
    } catch let error as ActivityAuthorizationError {
        switch error {
        case .denied:
            // User disabled Live Activities in Settings
            print("Live Activities disabled by user")
        case .globalMaximumExceeded:
            // Device-level ongoing Live Activity maximum reached
            print("System-wide activity limit reached")
        case .targetMaximumExceeded:
            // Too many Live Activities for this app
            print("App activity limit reached -- end an existing one first")
        default:
            print("Authorization error: \(error)")
        }
    } catch {
        print("Unexpected error: \(error)")
    }
}
```

## Background Handling Considerations

Live Activities continue to display when the app is backgrounded or suspended.
The Live Activity UI runs in a widget extension sandbox and cannot fetch network
data or receive location updates directly. Push-to-update is the primary
mechanism for background updates. When the app returns to foreground, reconcile
local state with the activity's current content.

```swift
@MainActor
func handleAppBecameActive() {
    // Reconcile local state with live activities on foregrounding
    let activities = Activity<DeliveryAttributes>.activities
    for activity in activities {
        switch activity.activityState {
        case .active:
            // Refresh from server in case pushes were missed
            Task {
                let serverState = try await ServerAPI.shared.fetchDeliveryState(
                    orderNumber: activity.attributes.orderNumber
                )
                let content = ActivityContent(
                    state: serverState,
                    staleDate: Date().addingTimeInterval(120),
                    relevanceScore: 80
                )
                await activity.update(content)
            }
        case .stale:
            // Content is outdated -- update immediately
            Task {
                let serverState = try await ServerAPI.shared.fetchDeliveryState(
                    orderNumber: activity.attributes.orderNumber
                )
                let content = ActivityContent(
                    state: serverState,
                    staleDate: Date().addingTimeInterval(120),
                    relevanceScore: 80
                )
                await activity.update(content)
            }
        case .ended, .dismissed:
            // Clean up local tracking
            break
        default:
            break
        }
    }
}
```

For truly background-driven updates, rely on push-to-update rather than
Background App Refresh. Push updates can arrive while the app is suspended, but
APNs delivery, priority, budget, and throttling still apply; use `staleDate` and
foreground reconciliation for missed updates.

## Testing in Simulator and on Device

### Simulator

The Simulator supports Live Activity rendering on the Lock Screen and displays
the Dynamic Island on simulator models that include Dynamic Island. Use Xcode
previews for rapid iteration:

```swift
#Preview("Lock Screen", as: .content, using: RideAttributes.preview) {
    RideActivityWidget()
} contentStates: {
    RideAttributes.ContentState(
        driverName: "Alex",
        driverPhoto: "car.fill",
        vehicleDescription: "White Toyota Camry",
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().addingTimeInterval(300).timeIntervalSince1970),
        status: .driverEnRoute,
        distanceRemaining: 1.5
    )
    RideAttributes.ContentState(
        driverName: "Alex",
        driverPhoto: "car.fill",
        vehicleDescription: "White Toyota Camry",
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().addingTimeInterval(60).timeIntervalSince1970),
        status: .arriving,
        distanceRemaining: 0.1
    )
}

#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: RideAttributes.preview) {
    RideActivityWidget()
} contentStates: {
    RideAttributes.ContentState(
        driverName: "Alex",
        driverPhoto: "car.fill",
        vehicleDescription: "White Toyota Camry",
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().addingTimeInterval(300).timeIntervalSince1970),
        status: .driverEnRoute,
        distanceRemaining: 1.5
    )
}

#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: RideAttributes.preview) {
    RideActivityWidget()
} contentStates: {
    RideAttributes.ContentState(
        driverName: "Alex",
        driverPhoto: "car.fill",
        vehicleDescription: "White Toyota Camry",
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().addingTimeInterval(300).timeIntervalSince1970),
        status: .driverEnRoute,
        distanceRemaining: 1.5
    )
}

#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: RideAttributes.preview) {
    RideActivityWidget()
} contentStates: {
    RideAttributes.ContentState(
        driverName: "Alex",
        driverPhoto: "car.fill",
        vehicleDescription: "White Toyota Camry",
        etaStartSeconds: Int(Date().timeIntervalSince1970),
        etaEndSeconds: Int(Date().addingTimeInterval(300).timeIntervalSince1970),
        status: .driverEnRoute,
        distanceRemaining: 1.5
    )
}
```

### Preview Data Helper

```swift
extension RideAttributes {
    static var preview: RideAttributes {
        RideAttributes(
            riderName: "Jordan",
            pickupLocation: "123 Main St",
            dropoffLocation: "456 Oak Ave"
        )
    }
}
```

### On Device

Test push-to-update by sending payloads through APNs using a tool like `curl`
or a push notification testing app. The Simulator does not support APNs, so
push-to-update must be tested on a physical device.

```bash
# Example curl command for APNs push update (HTTP/2)
curl -v \
  --http2 \
  --header "apns-push-type: liveactivity" \
  --header "apns-topic: com.example.app.push-type.liveactivity" \
  --header "apns-priority: 10" \
  --header "authorization: bearer $JWT_TOKEN" \
  --data '{"aps":{"timestamp":1700000000,"event":"update","content-state":{"driverName":"Alex","driverPhoto":"car.fill","vehicleDescription":"White Toyota Camry","etaStartSeconds":1700000000,"etaEndSeconds":1700000300,"status":"driverArrived","distanceRemaining":0.0},"alert":{"title":"Driver Arrived","body":"Your driver is here!"}}}' \
  https://api.push.apple.com/3/device/$DEVICE_PUSH_TOKEN
```

### Debugging Tips

- Check Console.app for `ActivityKit` log messages when activities fail to start.
- Verify `content-state` JSON keys match the default `ContentState` `Codable`
  shape or coordinated `CodingKeys`. Mismatches can prevent ActivityKit from
  applying updates.
- Use `Activity<T>.activities` to inspect all running activities in the debugger.
- Set a breakpoint in `pushTokenUpdates` to verify tokens are being delivered.
- If activities do not appear, confirm `NSSupportsLiveActivities = YES` is in
  the host app's Info.plist (not the widget extension's).

## Info.plist Keys Reference

| Key | Value | Purpose |
|---|---|---|
| `NSSupportsLiveActivities` | `YES` | Enable Live Activities (required) |
| `NSSupportsLiveActivitiesFrequentUpdates` | `YES` | Increase the system-managed push update budget |

Both keys belong in the host app's Info.plist, not the widget extension.

## Apple Documentation Links

- [ActivityKit](https://sosumi.ai/documentation/activitykit)
- [ActivityAttributes](https://sosumi.ai/documentation/activitykit/activityattributes)
- [Activity](https://sosumi.ai/documentation/activitykit/activity)
- [ActivityContent](https://sosumi.ai/documentation/activitykit/activitycontent)
- [ActivityAuthorizationInfo](https://sosumi.ai/documentation/activitykit/activityauthorizationinfo)
- [DynamicIsland](https://sosumi.ai/documentation/widgetkit/dynamicisland)
- [ActivityConfiguration](https://sosumi.ai/documentation/widgetkit/activityconfiguration)
- [Starting and updating with push notifications](https://sosumi.ai/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications)
- [Sending broadcast push notifications](https://sosumi.ai/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns)
