All articles
iOS10 min read

iOS Live Activities Widget Complete Guide: FocusFlow Part 2

Real-time session tracking with Dynamic Island, Lock Screen integration, and Live Activity management

K
Karan Pal
Author

In Part 1, we built the foundation of FocusFlow with basic widgets that show daily focus time and streaks. Now we’re going to add the really exciting stuff: Live Activities that track your focus sessions in real-time.

If you haven’t read Part 1, go back and build the foundation first. This article assumes you have the basic FocusFlow app with widgets already working.

**How to Build Your First iOS Widget with SwiftUI: FocusFlow Part 1** _Building widget fundamentals with timeline providers, App Groups, and responsive layouts_swift-pal.comhttps://swift-pal.com/how-to-build-your-first-ios-widget-with-swiftui-focusflow-part-1-baf13a21f69e

📺 Following along? The complete video walkthrough will be available on Swift Pal: https://youtube.com/@swift-pal

**Swift Pal** _👨‍💻 Welcome to Swift Pal - Your iOS Dev Companion! Whether you're just starting your journey with Swift and SwiftUI…_youtube.comhttps://youtube.com/@swift-pal

🎯 What We’re Adding

Live Activities for FocusFlow will:

🚀 Understanding Live Activities

Before we dive into code, let’s understand what Live Activities actually are and how they work fundamentally.

What Are Live Activities?

Live Activities are persistent, real-time notifications that display ongoing events directly on your Lock Screen and Dynamic Island. Think of them as “living widgets” that update automatically without user interaction.

Key characteristics:

Where Live Activities Appear

Live Activities live in two places:

Live Activity Architecture

Live Activities follow a specific architecture pattern:

Your App                    ActivityKit                 System UI
┌─────────────┐            ┌─────────────┐            ┌─────────────┐
│             │            │             │            │             │
│ Request()   │ ---------> │ Activity    │ ---------> │ Lock Screen │
│ Update()    │            │ Management  │            │ Dynamic     │
│ End()       │            │             │            │ Island      │
│             │            │             │            │             │
└─────────────┘            └─────────────┘            └─────────────┘

Live Activity Lifecycle

Every Live Activity follows this lifecycle:

  1. Request: Your app requests a new Live Activity with initial data
  2. Active: The activity is displayed and can receive updates
  3. Update: Your app pushes new data to update the UI (optional, multiple times)
  4. End: The activity is dismissed (manually or automatically)
  5. Cleanup: iOS removes it from Lock Screen/Dynamic Island

Data Flow Model

Live Activities use a unique data architecture:

Static Data (Attributes): Set once when requesting, never changes

Dynamic Data (ContentState): Updates during the activity’s lifetime

// Static - never changes during the activity
struct FocusSessionAttributes: ActivityAttributes {
    // Empty in our case - we put everything in ContentState
}

// Dynamic - updates as session progresses
struct ContentState: Codable, Hashable {
    let session: FocusSession      // Session details
    let progress: Double           // 0.0 to 1.0
    let timeRemaining: TimeInterval // Seconds left
    let isCompleted: Bool          // Session status
}

Update Mechanisms

Live Activities can update in three ways:

  1. App Updates (what we’ll use): Your app pushes updates via activity.update()
  2. Push Notifications: Server-sent updates for remote events
  3. Timer-based Views: Special SwiftUI views that auto-update every second

Timer-Aware Views (Critical!)

This is where Live Activities get special powers. Only certain SwiftUI views automatically update:

Auto-updating views:

Static views (freeze at initial value):

Performance & Battery Considerations

Live Activities are designed to be battery-efficient:

Permissions & Limitations

Now that we understand the foundation, let’s build our focus session Live Activity!

📊 Setting Up Activity Framework

First, we need to add the ActivityKit framework to our project.

Add ActivityKit to your main app and widget target:

  1. Select your target
  2. Build Phases → Link Binary With Libraries → + → ActivityKit.framework
  3. Repeat to add in the next target.

Update your **FocusData.swift** to support Live Activities (ADD TO BOTH app + widget targets):

Why BOTH? The widget (timeline + Live Activity UI) must read the same state (todaysFocusTime, active session) that the app mutates. One shared file prevents divergence.

import Foundation
import ActivityKit
import Observation

// Notification for session completion
extension Notification.Name {
    static let sessionCompleted = Notification.Name("sessionCompleted")
}
@Observable
class FocusData {
    static let shared = FocusData()
    
    var todaysFocusTime: TimeInterval = 0
    var currentStreak: Int = 0
    var lastSessionDate: Date?
    
    // Live Activity support
    var currentSession: FocusSession?
    var isSessionActive: Bool = false
    
    private let userDefaults = UserDefaults(suiteName: "group.com.yourname.focusflow")
    private let focusTimeKey = "todaysFocusTime"
    private let streakKey = "currentStreak"
    private let lastSessionKey = "lastSessionDate"
    private let lastResetDateKey = "lastResetDate"
    // Session snapshot keys (used by widget timeline & Live Activity awareness)
    private let isSessionActiveKey = "ff_isSessionActive"
    private let sessionRemainingKey = "ff_sessionRemaining"
    private let sessionTypeKey = "ff_sessionType"
    private let sessionStartTimeKey = "ff_sessionStartTime"
    private let sessionTargetDurationKey = "ff_sessionTargetDuration"
    private let sessionIdKey = "ff_sessionId"
    
    private init() {
        loadData()
        checkForNewDay()
    }
    
    func startFocusSession(duration: TimeInterval, sessionType: String = "Deep Work") {
        let session = FocusSession(
            id: UUID(),
            startTime: Date(),
            targetDuration: duration,
            sessionType: sessionType
        )
        
        currentSession = session
        isSessionActive = true
        // Persist snapshot for widget provider
        persistSessionSnapshot(active: true, remaining: session.targetDuration, type: session.sessionType)
        
        // Start Live Activity
        Task {
            await startLiveActivity(for: session)
        }
    }
    
    func completeFocusSession() {
        guard let session = currentSession else { return }
        
        let actualDuration = Date().timeIntervalSince(session.startTime)
        addFocusSession(duration: actualDuration)
        
        currentSession = nil
        isSessionActive = false
        // Clear snapshot for widget provider
        persistSessionSnapshot(active: false)
        
        // End Live Activity
        Task {
            await endLiveActivity(completed: true)
        }
    }
    
    func cancelFocusSession() {
        currentSession = nil
        isSessionActive = false
        // Clear snapshot for widget provider
        persistSessionSnapshot(active: false)
        
        // End Live Activity
        Task {
            await endLiveActivity(completed: false)
        }
    }
    
    func addFocusSession(duration: TimeInterval) {
        todaysFocusTime += duration
        updateStreak()
        lastSessionDate = Date()
        saveData()
    }
    
    private func updateStreak() {
        let calendar = Calendar.current
        let today = Date()
        
        if let lastSession = lastSessionDate {
            let daysSinceLastSession = calendar.dateComponents([.day], from: lastSession, to: today).day ?? 0
            
            if daysSinceLastSession == 0 {
                return
            } else if daysSinceLastSession == 1 {
                currentStreak += 1
            } else {
                currentStreak = 1
            }
        } else {
            currentStreak = 1
        }
    }
    
    private func checkForNewDay() {
        let calendar = Calendar.current
        let today = Date()
        
        if let lastReset = userDefaults?.object(forKey: lastResetDateKey) as? Date {
            if !calendar.isDate(lastReset, inSameDayAs: today) {
                resetDay()
                userDefaults?.set(today, forKey: lastResetDateKey)
            }
        } else {
            userDefaults?.set(today, forKey: lastResetDateKey)
        }
    }
    
    func resetDay() {
        todaysFocusTime = 0
        saveData()
    }
    
    private func loadData() {
        todaysFocusTime = userDefaults?.double(forKey: focusTimeKey) ?? 0
        currentStreak = userDefaults?.integer(forKey: streakKey) ?? 0
        lastSessionDate = userDefaults?.object(forKey: lastSessionKey) as? Date
        
        // Restore active session if it exists
        isSessionActive = userDefaults?.bool(forKey: isSessionActiveKey) ?? false
        if isSessionActive {
            restoreActiveSession()
        }
    }
    
    private func restoreActiveSession() {
        guard let startTime = userDefaults?.object(forKey: sessionStartTimeKey) as? Date,
              let sessionType = userDefaults?.string(forKey: sessionTypeKey),
              let sessionIdString = userDefaults?.string(forKey: sessionIdKey),
              let sessionId = UUID(uuidString: sessionIdString) else {
            // Invalid session data, clear it
            isSessionActive = false
            persistSessionSnapshot(active: false)
            return
        }
        
        let targetDuration = userDefaults?.double(forKey: sessionTargetDurationKey) ?? 0
        let elapsed = Date().timeIntervalSince(startTime)
        
        // Check if session should have already completed
        if elapsed >= targetDuration {
            // Session expired while app was closed, complete it
            let expiredSession = FocusSession(
                id: sessionId,
                startTime: startTime,
                targetDuration: targetDuration,
                sessionType: sessionType
            )
            currentSession = expiredSession
            completeFocusSession()
        } else {
            // Session is still active, restore it
            currentSession = FocusSession(
                id: sessionId,
                startTime: startTime,
                targetDuration: targetDuration,
                sessionType: sessionType
            )
        }
    }
    
    private func saveData() {
        userDefaults?.set(todaysFocusTime, forKey: focusTimeKey)
        userDefaults?.set(currentStreak, forKey: streakKey)
        userDefaults?.set(lastSessionDate, forKey: lastSessionKey)
    }
    
    // Persist complete session data for restoration after app restart
    private func persistSessionSnapshot(active: Bool, remaining: TimeInterval? = nil, type: String? = nil) {
        userDefaults?.set(active, forKey: isSessionActiveKey)
        userDefaults?.set(remaining, forKey: sessionRemainingKey)
        userDefaults?.set(type, forKey: sessionTypeKey)
        
        if active, let session = currentSession {
            // Save complete session data for restoration
            userDefaults?.set(session.startTime, forKey: sessionStartTimeKey)
            userDefaults?.set(session.targetDuration, forKey: sessionTargetDurationKey)
            userDefaults?.set(session.id.uuidString, forKey: sessionIdKey)
        } else {
            // Clear session data when not active
            userDefaults?.removeObject(forKey: sessionStartTimeKey)
            userDefaults?.removeObject(forKey: sessionTargetDurationKey)
            userDefaults?.removeObject(forKey: sessionIdKey)
        }
    }
}
// MARK: - Live Activity Management
extension FocusData {
    @MainActor
    private func startLiveActivity(for session: FocusSession) async {
        let attributes = FocusSessionAttributes()
        let initialState = FocusSessionAttributes.ContentState(
            session: session,
            progress: 0.0,
            timeRemaining: session.targetDuration
        )
        
        do {
            // iOS 16.2+ API: use content: ActivityContent instead of deprecated contentState:
            let content = ActivityContent(state: initialState, staleDate: nil)
            let activity = try Activity<FocusSessionAttributes>.request(
                attributes: attributes,
                content: content,
                pushType: nil
            )
            print("Started Live Activity: \(activity.id)")
        } catch {
            print("Failed to start Live Activity: \(error)")
        }
    }
    
    @MainActor
    private func endLiveActivity(completed: Bool) async {
        for activity in Activity<FocusSessionAttributes>.activities {
            let finalState = FocusSessionAttributes.ContentState(
                session: activity.content.state.session,
                progress: completed ? 1.0 : activity.content.state.progress,
                timeRemaining: completed ? 0 : activity.content.state.timeRemaining,
                isCompleted: completed
            )
            
            await activity.end(
                ActivityContent(state: finalState, staleDate: nil),
                dismissalPolicy: .immediate
            )
        }
    }
    
    func updateLiveActivityProgress() {
        guard let session = currentSession else { return }
        
        let elapsed = Date().timeIntervalSince(session.startTime)
        let progress = min(elapsed / session.targetDuration, 1.0)
        let remaining = max(session.targetDuration - elapsed, 0)
        // Persist updated remaining time for widget provider countdown
        persistSessionSnapshot(active: true, remaining: remaining, type: session.sessionType)
        
        Task { @MainActor in
            for activity in Activity<FocusSessionAttributes>.activities {
                let updatedState = FocusSessionAttributes.ContentState(
                    session: session,
                    progress: progress,
                    timeRemaining: remaining
                )
                
                await activity.update(
                    ActivityContent(state: updatedState, staleDate: nil)
                )
            }
        }
    }
}
// Helper models
// Declare Codable & Hashable here so synthesis works (ActivityKit needs it)
struct FocusSession: Codable, Hashable {
    let id: UUID
    let startTime: Date
    let targetDuration: TimeInterval
    let sessionType: String
}
extension TimeInterval {
    var formattedDuration: String {
        let hours = Int(self) / 3600
        let minutes = Int(self) % 3600 / 60
        
        if hours > 0 {
            return "\(hours)h \(minutes)m"
        } else {
            return "\(minutes)m"
        }
    }
    
    var formattedCountdown: String {
        let minutes = Int(self) / 60
        let seconds = Int(self) % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }
}
IMPORTANT: Remove any duplicate TimeInterval formatting extension (for example one still living in FocusFlowWidget.swift from Part 1). Additionally, you can keep a single shared TimeInterval+Formatting.swift file added to BOTH targets to avoid Invalid redeclaration compiler errors.

🏗️ Activity Attributes and Content State

Live Activities need two key components: Attributes (static data) and ContentState (dynamic data that changes).

Create **FocusSessionActivity.swift** (ADD TO BOTH app + widget targets):

The app requests/updates/ends the Live Activity; the widget renders it. Missing target membership on either side causes build errors or a non‑rendering activity.

import ActivityKit
import Foundation

struct FocusSessionAttributes: ActivityAttributes {
    public typealias FocusSessionStatus = ContentState

    public struct ContentState: Codable, Hashable {
        let session: FocusSession
        let progress: Double
        let timeRemaining: TimeInterval
        let isCompleted: Bool

        init(session: FocusSession,
             progress: Double,
             timeRemaining: TimeInterval,
             isCompleted: Bool = false) {
            self.session = session
            self.progress = progress
            self.timeRemaining = timeRemaining
            self.isCompleted = isCompleted
        }
    }
}

🎨 Creating Live Activity Views

Now comes the fun part — designing the Live Activity UI. We need both Dynamic Island and Lock Screen views.

Add these views in your Widget Extension target:

import SwiftUI
import ActivityKit
import WidgetKit

struct FocusSessionLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FocusSessionAttributes.self) { context in
            // Lock Screen view
            FocusSessionLockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded view
                DynamicIslandExpandedRegion(.leading) {
                    // Use timer interval for precise countdown that stops at 00:00
                    let start = context.state.session.startTime
                    let end = start.addingTimeInterval(context.state.session.targetDuration)
                    VStack(alignment: .leading, spacing: 2) {
                        Text(context.state.session.sessionType)
                            .font(.caption)
                            .foregroundColor(.secondary)
                        Text(timerInterval: start...end, countsDown: true)
                            .font(.headline)
                            .fontWeight(.semibold)
                            .monospacedDigit()
                    }
                }
                
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(spacing: 2) {
                        Image(systemName: "brain.head.profile")
                            .font(.title2)
                            .foregroundColor(.blue)
                        
                        Text("Active")
                            .font(.caption)
                            .foregroundColor(.green)
                    }
                }
                
                DynamicIslandExpandedRegion(.bottom) {
                    let end = context.state.session.startTime.addingTimeInterval(context.state.session.targetDuration)
                    ProgressView(timerInterval: context.state.session.startTime...end, countsDown: false)
                        .progressViewStyle(LinearProgressViewStyle(tint: .blue))
                }
            } compactLeading: {
                Image(systemName: "brain.head.profile")
                    .foregroundColor(.blue)
            } compactTrailing: {
                let start = context.state.session.startTime
                let end = start.addingTimeInterval(context.state.session.targetDuration)
                Text(timerInterval: start...end, countsDown: true)
                    .font(.caption2)
                    .fontWeight(.semibold)
            } minimal: {
                Image(systemName: "brain.head.profile")
                    .foregroundColor(.blue)
            }
        }
    }
}

struct FocusSessionLockScreenView: View {
    let context: ActivityViewContext<FocusSessionAttributes>
    
    var body: some View {
        VStack(spacing: 12) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    Text("FocusFlow Session")
                        .font(.headline)
                        .foregroundColor(.primary)
                    
                    Text(context.state.session.sessionType)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                
                Spacer()
                
                VStack(alignment: .trailing, spacing: 4) {
                    let end = context.state.session.startTime.addingTimeInterval(context.state.session.targetDuration)
                    Text(end, style: .timer)
                        .font(.title2)
                        .fontWeight(.bold)
                        .foregroundColor(.blue)
                    
                    Text("In Progress")
                        .font(.caption)
                        .foregroundColor(.green)
                }
            }
            
            let end = context.state.session.startTime.addingTimeInterval(context.state.session.targetDuration)
            ProgressView(timerInterval: context.state.session.startTime...end, countsDown: false)
                .progressViewStyle(LinearProgressViewStyle(tint: .blue))
            
            if context.state.isCompleted {
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundColor(.green)
                    Text("Focus session completed!")
                        .font(.subheadline)
                        .foregroundColor(.green)
                }
            }
        }
        .padding()
        .background(Color(.systemBackground))
    }
}
Live Activity Timer Views: Live Activities severely restrict UI updates. Only few of the time aware UI elements like Text(date, style: .timer) and ProgressView(timerInterval:countsDown:) auto-update every second. Regular Text views showing percentages or other calculated values remain frozen at their initial state. The timer above only handles completion detection and widget updates - it doesn't affect Live Activity UI.

🔄 Real-Time Updates

Live Activities need regular updates to show progress. Let’s add a timer system to our main app.

Update your **ContentView.swift**:

import SwiftUI

struct ContentView: View {
    @State private var focusData = FocusData.shared
    // Replaces simple Timer with an async Task loop for reliable second ticks
    @State private var sessionTickerTask: Task<Void, Never>? = nil
    
    var body: some View {
        NavigationView {
            VStack(spacing: 30) {
                // Today's Stats
                VStack(spacing: 16) {
                    Text("Today's Focus Time")
                        .font(.headline)
                        .foregroundColor(.secondary)
                    
                    Text(focusData.todaysFocusTime.formattedDuration)
                        .font(.largeTitle)
                        .fontWeight(.bold)
                        .foregroundColor(.blue)
                    
                    HStack(spacing: 20) {
                        VStack {
                            Text("\(focusData.currentStreak)")
                                .font(.title2)
                                .fontWeight(.bold)
                                .foregroundColor(.orange)
                            Text("Day Streak")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                        
                        Divider()
                            .frame(height: 40)
                        
                        VStack {
                            Text("25m")
                                .font(.title2)
                                .fontWeight(.bold)
                                .foregroundColor(.green)
                            Text("Next Session")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                }
                .padding()
                .background(Color(.systemGray6))
                .clipShape(RoundedRectangle(cornerRadius: 12))
                
                // Current Session Status
                if focusData.isSessionActive, let session = focusData.currentSession {
                    CurrentSessionView(session: session)
                }
                
                Spacer()
                
                // Session Controls
                VStack(spacing: 16) {
                    Text("Quick Focus Session")
                        .font(.headline)
                    
                    if !focusData.isSessionActive {
                        HStack(spacing: 12) {
                            SessionButton(title: "25m", subtitle: "Pomodoro", duration: 25 * 60) {
                                startSessionTimer()
                            }
                            SessionButton(title: "45m", subtitle: "Deep Work", duration: 45 * 60) {
                                startSessionTimer()
                            }
                            SessionButton(title: "90m", subtitle: "Flow State", duration: 90 * 60) {
                                startSessionTimer()
                            }
                        }
                    } else {
                        HStack(spacing: 16) {
                            Button("Complete Session") {
                                focusData.completeFocusSession()
                                stopSessionTimer()
                            }
                            .buttonStyle(.borderedProminent)
                            
                            Button("Cancel") {
                                focusData.cancelFocusSession()
                                stopSessionTimer()
                            }
                            .buttonStyle(.bordered)
                        }
                    }
                }
                
                Spacer()
            }
            .padding()
            .navigationTitle("FocusFlow")
            .onAppear {
                // Start timer if session was restored from UserDefaults
                if focusData.isSessionActive {
                    startSessionTimer()
                }
            }
        }
    }
    
    private func startSessionTimer() {
        sessionTickerTask?.cancel()
        sessionTickerTask = Task { [weak focusData] in
            while let data = focusData, data.isSessionActive {
                // Only for completion detection and widget updates - NOT for Live Activity UI
                let session = data.currentSession!
                let elapsed = Date().timeIntervalSince(session.startTime)
                let remaining = session.targetDuration - elapsed
                let isComplete = elapsed >= session.targetDuration
                
                if isComplete {
                    await MainActor.run { 
                        data.completeFocusSession()
                        // Force UI update when app is in foreground
                        NotificationCenter.default.post(name: .sessionCompleted, object: nil)
                    }
                    break
                }
                
                // Update widget timeline (separate from Live Activity)
                await MainActor.run { data.updateLiveActivityProgress() }
                
                // Check more frequently as we approach completion
                let checkInterval: Duration = remaining < 10 ? .seconds(1) : .seconds(5)
                try? await Task.sleep(for: checkInterval)
            }
        }
    }
    
    private func stopSessionTimer() {
        sessionTickerTask?.cancel()
        sessionTickerTask = nil
    }
}

struct SessionButton: View {
    let title: String
    let subtitle: String
    let duration: TimeInterval
    let onSessionStart: () -> Void
    
    var body: some View {
        Button {
            FocusData.shared.startFocusSession(duration: duration, sessionType: subtitle)
            onSessionStart()
        } label: {
            VStack(spacing: 4) {
                Text(title)
                    .font(.title2)
                    .fontWeight(.bold)
                Text(subtitle)
                    .font(.caption)
            }
            .frame(maxWidth: .infinity)
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .clipShape(RoundedRectangle(cornerRadius: 8))
        }
    }
}

struct CurrentSessionView: View {
    let session: FocusSession
    @State private var now = Date()
    private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack(spacing: 12) {
            Text("Current Session")
                .font(.headline)
                .foregroundColor(.secondary)
            
            Text(session.sessionType)
                .font(.title2)
                .fontWeight(.semibold)
            
            let elapsed = now.timeIntervalSince(session.startTime)
            let remaining = max(session.targetDuration - elapsed, 0)
            
            Text(remaining.formattedCountdown)
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.blue)
                .monospacedDigit()
            
            ProgressView(value: elapsed / session.targetDuration)
                .progressViewStyle(LinearProgressViewStyle(tint: .blue))
        }
        .onReceive(ticker) { now = $0 }
        .padding()
        .background(Color(.systemGray6))
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

📱 Widget Extension Updates

Your existing widgets from Part 1 remain unchanged. The only update needed is adding the Live Activity to your widget bundle.

Update your Widget Bundle to include the Live Activity:

import WidgetKit
import SwiftUI

@main
struct FocusFlowWidgetBundle: WidgetBundle {
    var body: some Widget {
        FocusFlowWidget()           // Your existing widget from Part 1
        FocusSessionLiveActivity()  // New Live Activity
    }
}

Update **FocusProvider** to focus on core metrics:

struct FocusProvider: TimelineProvider {
    func placeholder(in context: Context) -> FocusEntry {
        FocusEntry(date: Date(), todaysFocusTime: 25*60, currentStreak: 3)
    }

    func getSnapshot(in context: Context, completion: @escaping (FocusEntry) -> ()) {
        completion(loadEntry())
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<FocusEntry>) -> ()) {
        let now = Date()
        let current = loadEntry(date: now)
        let entries: [FocusEntry] = [current]

        // Refresh every 2 hours since widgets now show static status, not live countdown
        let refreshIntervalMinutes = 120
        let refreshDate = Calendar.current.date(byAdding: .minute, value: refreshIntervalMinutes, to: now) ?? Date().addingTimeInterval(120 * 60)
        completion(Timeline(entries: entries, policy: .after(refreshDate)))
    }

    // MARK: - Data Loading
    private func loadEntry(date: Date = Date()) -> FocusEntry {
        let ud = UserDefaults(suiteName: "group.com.yourname.focusflow")
        let focusTime = ud?.double(forKey: "todaysFocusTime") ?? 0
        let streak = ud?.integer(forKey: "currentStreak") ?? 0
        return FocusEntry(
            date: date,
            todaysFocusTime: focusTime,
            currentStreak: streak
        )
    }
}

⚙️ Permissions and Info.plist

Live Activities require specific permissions. Add this to your main app’s Info.plist:

<key>NSSupportsLiveActivities</key>
<true/>

And request permission in your app startup:

import SwiftUI
import ActivityKit

@main
struct FocusFlowApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    requestLiveActivityPermission()
                }
                .onOpenURL { url in
                    if url.scheme == "focusflow" {
                        print("Opened from widget!")
                    }
                }
        }
    }
    
    private func requestLiveActivityPermission() {
        Task {
            if ActivityAuthorizationInfo().frequentPushesEnabled {
                print("Live Activities are enabled")
            } else {
                print("Live Activities are disabled")
            }
        }
    }
}

🧪 Testing Live Activities

Testing Live Activities requires some specific steps:

  1. Use a real device — Sometimes Live Activities don’t work well in simulator
  2. Enable Live Activities in Settings → Face ID & Passcode → Allow Access When Locked
  3. Test Dynamic Island on iPhone 14 Pro or later
  4. Check Lock Screen behavior with different wallpapers

✅ Correctness & Target Membership Audit

Before moving on, verify these or you will hit silent failures:

Target membership (File Inspector → Target Membership):

API usage:

Common mistakes:

Sanity test sequence:

  1. Start session → console prints “Started Live Activity: …”
  2. Lock device → Lock Screen shows progress
  3. On Dynamic Island device → compact view shows countdown
  4. Complete session → Live Activity ends promptly
  5. Focus time total increases; widget updates on next refresh / manual reload.

🎯 What We’ve Accomplished

You just added Live Activities to FocusFlow! Here’s what you built:

Real-time session tracking with Live Activities ✅ Dynamic Island integration for iPhone 14 Pro+ ✅ Lock Screen widgets with interactive controls ✅ Seamless start/stop workflow from main app ✅ Progress updates every second during sessions

📺 Watch the complete implementation: Video tutorial available on Swift Pal: https://youtube.com/@swift-pal

🔧 Key Live Activity Patterns

Activity Lifecycle: Request → Update → End with proper ContentState management

Real-time Updates: Use timers in your main app to push updates to Live Activities

User Interaction: Live Activities can have buttons, but they should trigger AppIntents or deep links

Design Constraints: Dynamic Island space is limited — prioritize essential information

Performance: Live Activities are meant for short-term events, not permanent displays

🚀 Series Complete

We’ve built something sophisticated here! The foundation from Part 1 plus Live Activities from Part 2 create a complete, production-ready productivity app with:

The architecture decisions we made — shared data layer, timeline management, proper separation of concerns — create a solid foundation that can be extended with additional features as needed.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading