PEAKIQ - Software Solutions & Digital Innovation Peakiq Software Development

Peakiq Blog

Building a Live Timer with iOS Live Activities in React Native

Step-by-step guide to integrating iOS Live Activities with React Native. Build a live timer that updates on the Lock Screen and Dynamic Island using Swift native modules.

Editorial11 min read2069 words
Building a Live Timer with iOS Live Activities in React Native

Integrate Lock Screen, Dynamic Island, and Real-Time Updates with Native Swift + React Native

βœ… Timer updates in real time
βœ… Dynamic Island + Lock Screen support
βœ… Start/Update/Stop from JS
βœ… Success feedback using `react-native-flash-message`
πŸ“± Final Output

  • Start a timer from React Native

  • Live Activity shows:

    • ⏱ Timer since start
    • πŸ“… Started date
    • πŸ†” Dynamic ID (like "1823")
  • User can stop the timer and see a success flash

Steps to change the deployment target:

  1. Go to your main target in Xcode.

  2. Navigate to General > Deployment Info.

  3. Change the Minimum Deployment Target to 16.1.

    Enabling Live Activities in info.plist

    After setting up the widget, we need to update the info.plist file to declare that the app supports Live Activities. To do this, go to the info.plist and hover the mouse over the Informal Property List until a '+' appears:

    Next, you need to select 'Supports Live Activities' from the list:



Step 1: Create a Native Module

βœ… Step 1: Open Xcode

  • Navigate to ios/YourApp.xcworkspace
  • Open in Xcode, not VS Code

βœ… Step 2: Add a new Swift file

  1. Right-click on the YourApp folder inside Xcode (not the blue project icon)
  2. Select New File β†’ Swift File
  3. Name it TimeRecordWidget.swift
  4. When asked, choose "Create Bridging Header" β†’ YES

This allows Swift to work with Objective-C.

βœ… Step 3: Add your native module code

Paste the following in TimeRecordWidget.swift

:

//
//  TimeRecordWidget.swift
//
//  Created by Manoj on 16/07/25.
//

import Foundation
import ActivityKit
import React

@objc(TimeRecordWidget)
class TimeRecordWidget: NSObject {
  var pulseTimer: Timer?
  var pulseStep = 0
  
  @objc(startActivity:)
  func startActivity(data: NSDictionary) {
    print("πŸ“₯ [Swift] start Activity called with data: \(data)")
    NSLog("πŸ“₯ [Swift] start Activity called with data: %@", data)

    guard let recordId = data["recordId"] as? String,
          let username = data["username"] as? String,
          let name = data["name"] as? String,
          let startDateString = data["startDate"] as? String else {
        print("❌ Live Activity: Missing required fields")
        NSLog("❌ Live Activity: Missing required fields")
        return
    }

    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

    guard let startDate = formatter.date(from: startDateString) else {
        print("❌ Live Activity: Invalid date format: \(startDateString)")
        NSLog("❌ Live Activity: Invalid date format: %@", startDateString)
        return
    }

    print("βœ… Parsed Start Date: \(startDate)")
    NSLog("βœ… Parsed Start Date: %@", startDate as NSDate)


      Task {
          if #available(iOS 16.1, *) {
              let attributes = TimeRecoredWidgetAttributes(
                  name: name,
                  recordId: recordId,
                  username: username
              )

            let contentState = TimeRecoredWidgetAttributes.ContentState(
                startDate: startDate,
                pulseScale: 1.0,       // start with default scale
                pulseOpacity: 0.7     // start with default opacity
            )
            
              let content = ActivityContent(state: contentState, staleDate: nil)

              do {
                  let activity = try Activity<TimeRecoredWidgetAttributes>.request(
                      attributes: attributes,
                      content: content,
                      pushType: nil
                  )
                  print("βœ… Started Live Activity: \(activity.id)")
                  startPulseTimer(activity: activity)
                 
              } catch {
                  print("❌ Failed to start Live Activity: \(error)")
              }
          }
      }
  }
  
  


  func updatePulseState(activity: Activity<TimeRecoredWidgetAttributes>, step: Int) {
      let pulseStates: [(CGFloat, Double)] = [
          (1.0, 0.7),
          (1.2, 1.0)
      ]
      
      let pulseScale = pulseStates[step % pulseStates.count].0
      let pulseOpacity = pulseStates[step % pulseStates.count].1
      
      print("Updating pulse state - step: \(step), pulseScale: \(pulseScale), pulseOpacity: \(pulseOpacity)")
      
      let newState = TimeRecoredWidgetAttributes.ContentState(
          startDate: activity.content.state.startDate,
          pulseScale: pulseScale,
          pulseOpacity: pulseOpacity
      )
     
      Task {
          let content = ActivityContent(state: newState, staleDate: nil)
          print("Calling activity.update with new content state")
          await activity.update(
              content,
              alertConfiguration: nil
          )
          print("activity.update completed")
      }
  }

  func startPulseTimer(activity: Activity<TimeRecoredWidgetAttributes>) {
          print("Starting pulse timer new")

          // Invalidate previous timer if any
          pulseTimer?.invalidate()
          pulseTimer = nil
          pulseStep = 0
          print("Schedule timer")
          // Schedule timer on main run loop explicitly
          pulseTimer = Timer(timeInterval: 1.0, repeats: true) { [weak self] timer in
              guard let self = self else {
                  print("Self is nil, invalidating timer")
                  timer.invalidate()
                  return
              }
              self.pulseStep += 1
              print("Timer fired - pulseStep: \(self.pulseStep)")
              self.updatePulseState(activity: activity, step: self.pulseStep)

              if activity.activityState != .active {
                  print("Activity is no longer active, invalidating timer")
                  timer.invalidate()
                  self.pulseTimer = nil
              }
          }

          // Add timer to main run loop
          if let pulseTimer = pulseTimer {
              RunLoop.main.add(pulseTimer, forMode: .common)
          }
      }
  
  @objc(endActivity)
  func endActivity() {
    Task {
      if #available(iOS 16.1, *) {
        for activity in Activity<TimeRecoredWidgetAttributes>.activities {
          let currentState = activity.content.state
          let finalState = TimeRecoredWidgetAttributes.ContentState(
               startDate: currentState.startDate,
               pulseScale: currentState.pulseScale,
               pulseOpacity: currentState.pulseOpacity
          )

          let finalContent = ActivityContent(state: finalState, staleDate: nil)

          await activity.end(finalContent, dismissalPolicy: .immediate)
          print("πŸ›‘ Ended activity: \(activity.id)")
        }
      } else {
        print("❌ Live Activities not supported")
      }
    }
  }
  


  @objc(checkPermission:rejecter:)
   func checkPermission(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
     if #available(iOS 16.2, *) {
       let isEnabled = ActivityAuthorizationInfo().areActivitiesEnabled
       resolve(isEnabled)
     } else {
       resolve(true) // iOS < 16.2
     }
   }

  
}



Step 2: Register in Objective-C Header

βœ… Step 1: New file β†’ Objective-C file

  1. Right-click the same group (YourApp)
  2. Select New File β†’ Objective-C File
  3. Name it TimeRecoredWidgetHeader.m
  4. It may ask to create a Bridging Header again β€” skip if you already did.

βœ… Paste this content

Paste the following in TimeRecoredWidgetHeader.m:

//
//  TimeRecoredWidgetHeader.m
//
//  Created by Manoj on 16/07/25.
//

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(TimeRecordWidget, NSObject)

RCT_EXTERN_METHOD(startActivity:(NSDictionary *)data)
RCT_EXTERN_METHOD(endActivity)
RCT_EXTERN_METHOD(checkPermission:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)


@end


Step 3: Define Attributes and Widget and Live Activity Widget UI

Create a New Widget Extension

  1. In Xcode, go to File β†’ New β†’ Target…

  2. Search for β€œWidget Extension”

  3. Select Widget Extension

  4. Name it TimeRecoredWidget

  5. βœ… Enable β€œInclude Live Activity” when asked

  6. Finish and click "Activate" when it asks

  7. make sure the file is compiled in your main app target

    Open Xcode, then:

    • Click TimeRecoredWidgetLiveActivity.swift

    • In the right sidebar (File Inspector), check Target Membership

      • βœ… Ensure your app target (e.g. ComplyStation) is checked β€” not just the widget extension.

    This is the most common cause.

βœ… This will create:

  • TimeRecoredWidget.swift
  • TimeRecoredWidgetLiveActivity.swift
  • `TimeRecoredWidgetBundle.swift`
  • Paste the following in TimeRecoredWidgetLiveActivity.swift: Define Attributes and Widget and Live Activity Widget UI exist into the this file
//
//  TimeRecoredWidgetLiveActivity.swift
//  TimeRecoredWidget
//
//  Created by Manoj on 16/07/25.
//

import ActivityKit
import WidgetKit
import SwiftUI


struct TimeRecoredWidgetLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TimeRecoredWidgetAttributes.self) { context in
            // πŸ”’ Lock Screen / Banner UI
            lockScreenView(context: context)
        } dynamicIsland: { context in
            // 🏝️ Dynamic Island UI
            dynamicIslandView(context: context)
        }
    }
}


// MARK: πŸ”’ Lock Screen View
@ViewBuilder
func lockScreenView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> some View {
    VStack(alignment: .leading, spacing: 12) {
        // πŸ”· App icon + Title + ID + Timer
      HStack(alignment: .center) {
        Image("action_icon")
          .resizable()
          .frame(width: 32, height: 32)
          .clipShape(RoundedRectangle(cornerRadius: 6))
        
        VStack(alignment: .leading, spacing: 2) {
          Text((context.attributes.name))
            .font(.headline)
            .foregroundColor(.white)
          
          Text("ID: \(context.attributes.recordId)")
            .font(.footnote)
            .foregroundColor(.white)
        }
        
        Spacer()
        
        // Timer
        HStack(spacing: 8) {
            Image(systemName: "clock")
                .font(.footnote)
                .foregroundColor(.red)

            // ⏱ Live-updating timer
            Text(context.state.startDate, style: .relative)
                .font(.headline)
                .monospacedDigit()
              .foregroundColor(.red)

        }
      }

        // ℹ️ Extra Info + Stop
        HStack {
            VStack(alignment: .leading, spacing: 4) {
              Text("Started at \(formattedDate(context.state.startDate))")
                .font(.footnote)
                  .foregroundColor(.white)

              Text("User: \(context.attributes.username)")                   .font(.footnote)
                    .foregroundColor(.white)
            }

            Spacer()

            // πŸŸ₯ Stop Button
            Link(destination: URL(string: "gopak360://stop-timer/\(context.attributes.recordId)")!) {
                ZStack {
                    Circle()
                        .fill(Color.black)
                        .frame(width: 36, height: 36)
                        .overlay(Circle().stroke(Color.red, lineWidth: 2))

                    Image(systemName: "stop.fill")
                        .font(.system(size: 14, weight: .bold))
                        .foregroundColor(.red)
                }
                .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2)
            }
        }
    }
    .padding()
}

// MARK: 🏝️ Dynamic Island View
func dynamicIslandView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> DynamicIsland {
    DynamicIsland {
        // 🧠 Center Region (Main content)
      DynamicIslandExpandedRegion(.center) {
          HStack {
              // πŸ”· App Icon
              Image("action_icon")
                  .resizable()
                  .frame(width: 24, height: 24)
                  .clipShape(RoundedRectangle(cornerRadius: 4))

              // πŸ“ Title + ID
              VStack(alignment: .leading, spacing: 2) {
                Text((context.attributes.name))
                      .font(.headline)
                      .foregroundColor(.white)
                      .lineLimit(1)

                  Text("ID: \(context.attributes.recordId)")
                      .font(.footnote)
                      .foregroundColor(.white)
              }

              Spacer() // Pushes timer + dot to the right

              // ⏱ Timer + πŸ”΄ Dot side-by-side
            HStack(spacing: 8) {
                Image(systemName: "clock")
                    .font(.footnote)
                    .foregroundColor(.red)

                // ⏱ Live-updating timer
                Text(context.state.startDate, style: .relative)
                    .font(.headline)
                    .monospacedDigit()
                    .foregroundColor(.red)

            }
          }
          .padding(.horizontal, 12)
      }

        // ⬇️ Bottom Region (Extra Info + Stop)
        DynamicIslandExpandedRegion(.bottom) {
            HStack {
                VStack(alignment: .leading) {
                    Text("Started at \(formattedDate(context.state.startDate))")
                        .font(.footnote)
                        .foregroundColor(.white)

                    Text("User: \(context.attributes.username)")
                        .font(.footnote)
                        .foregroundColor(.white)
                }

                Spacer()

                Link(destination: URL(string: "gopak360://stop-timer/\(context.attributes.recordId)")!) {
                    ZStack {
                        Circle()
                            .fill(Color.black)
                            .frame(width: 36, height: 36)
                            .overlay(Circle().stroke(Color.red, lineWidth: 2))

                        Image(systemName: "stop.fill")
                            .font(.system(size: 14, weight: .bold))
                            .foregroundColor(.red)
                    }
                    .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2)
                }
            }
            .padding(.horizontal, 12)
        }

    } compactLeading: {
      HStack(spacing: 4) {

               Image("action_icon")
                  .resizable()
                  .frame(width: 28, height: 28)
  
        
      
         }
    }compactTrailing: {
      HStack(spacing: 4) {
                 
                  Text(context.state.startDate, style: .timer)
                 .font(.system(size: 16, weight: .heavy))
                 .foregroundColor(.red)
        
                Circle()
                .fill(Color.red)
                .frame(width: 20, height: 20)
                .opacity(context.state.pulseOpacity)
                .scaleEffect(context.state.pulseScale)
                .animation(.easeInOut(duration: 0.5), value: context.state.pulseOpacity)
                    
        
        
          }
     } minimal: {
        Circle()
            .fill(Color.red)
            .frame(width: 10, height: 10)
     }
    .widgetURL(URL(string: "gopak360://action-timer/\(context.attributes.recordId)"))
    .keylineTint(.red)
}

// MARK: 🧩 Leading Region View
@ViewBuilder
func dynamicIslandLeadingView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> some View {
    HStack(spacing: 8) {
        // 🟣 App icon
        Image("action_icon")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 24, height: 24)
            .padding(3)
            .clipShape(RoundedRectangle(cornerRadius: 4))
      Text((context.attributes.username))
        // πŸ“ Full-width text
        VStack(alignment: .leading, spacing: 0) {
          Text("\(context.attributes.name) - \(context.attributes.recordId)")
                .font(.subheadline)
                .foregroundColor(.primary)
                .lineLimit(2)
                .multilineTextAlignment(.leading)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .layoutPriority(10) // ⬆️ Force max width
    }
    .frame(maxWidth: .infinity, alignment: .leading) // ⬅️ Make entire HStack expand
    .padding(.horizontal, 4)
}

// MARK: πŸ”΄ Trailing Region View
@ViewBuilder
func dynamicIslandTrailingView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> some View {
    HStack(spacing: 4) {
        Text(context.state.startDate, style: .timer)
            .monospacedDigit()
            .font(.headline)
            .foregroundColor(.red)

        ZStack {
            Circle()
                .fill(Color.red)
                .frame(width: 15, height: 15)

          StaticRecordingIndicator()
                .frame(width: 12, height: 12)
               
        }
        .padding(.trailing, 2)
    }
}

// MARK: πŸŸ₯ Bottom Region View (Stop Button)
@ViewBuilder
func dynamicIslandBottomView(context: ActivityViewContext<TimeRecoredWidgetAttributes>) -> some View {
    Link(destination: URL(string: "gopak360://stop-timer/\(context.attributes.recordId)")!) {
        ZStack {
            Circle()
                .fill(Color.black)
                .frame(width: 36, height: 36)
                .overlay(
                    Circle()
                        .stroke(Color.red, lineWidth: 2)
                )

            Image(systemName: "stop.fill")
                .font(.system(size: 18, weight: .bold))
                .foregroundColor(.red)
        }
        .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2)
    }
}

// MARK: πŸ”΄ Blinking Red Dot View
struct StaticRecordingIndicator: View {
    var body: some View {
            HStack(spacing: 4) {
                Circle()
                    .fill(Color.red)
                    .frame(width:15, height: 15)
        }
    }
}

func formattedDate(_ date: Date) -> String {
    let formatter = DateFormatter()
    formatter.dateFormat = "dd MMM, h:mm a"
    return formatter.string(from: date)
}




TimeRecoredWidgetAttributes.swift

//
//  TimeRecoredWidgetAttributes.swift
//  ComplyStation
//
//  Created by Manoj on 12/08/25.
//

import Foundation
import ActivityKit
import SwiftUI

public struct TimeRecoredWidgetAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var startDate: Date
        var pulseScale: CGFloat  
        var pulseOpacity: Double 
    }

    var name: String
    var recordId: String
    var username: String
}



πŸ§ͺ React Native Integration

1. Triggering Start from JS

import { Alert, NativeModules, Platform } from 'react-native'
import React from 'react'
import styled from 'styled-components/native';
import  { showMessage } from "react-native-flash-message";
import { checkLiveActivityPermission,showLiveActivityPermissionAlert } from '@utils/checkLiveActivityPermission';
import { StartActivityPayload, TimeRecordWidgetType } from 'src/types';

const TimeRecordWidget = NativeModules.TimeRecordWidget as
 TimeRecordWidgetType;
 
const payload: StartActivityPayload = {
  recordId: "1823",
  username: "manoj@lisam.com",
  name: "Time Record",
  startDate: new Date().toISOString()
};

console.info({TimeRecordWidget});

  const onEndActivity = () => {
  Alert.alert(
    'Stop Timer',
    'Are you sure you want to stop the timer?',
    [
      {
        text: 'Cancel',
        style: 'cancel',
      },
      {
        text: 'Stop',
        style: 'destructive',
        onPress: () => {
          TimeRecordWidget?.endActivity();

          // βœ… Flash message
          showMessage({
            message: '⏹️ Timer stopped successfully',
            type: 'success',
            duration: 2000,
            autoHide: false,
            hideOnPress: true,
            icon: 'auto',
            floating: true,
          });
        },
      },
    ],
    { cancelable: true }
  );
};

  const OnStartActivity=async()=>{
       if(Platform.OS==='ios')
       try {
        const result = await checkLiveActivityPermission();
        if(!result){
          showLiveActivityPermissionAlert();
          return;
        }
        console.info({payload})
        TimeRecordWidget?.startActivity(payload)
       } catch (error) {
        console.info(error)
       }
  }

const LiveActivityTest = () => {

  
  return (
    <Container>
      <Button title={`Start Time Record Activity ${payload?.recordId}` } 
      onPress={OnStartActivity}/>
      <Button title="Stop Time Record Activity" onPress={onEndActivity} />
    </Container>
  )
}

export default LiveActivityTest;

const Container = styled.View`
      flex:1;
      justify-content: center;
      background-color: white;
      align-items: center;
`;

const Button=styled.Button``;


2. End with Confirmation + Flash Message

 const onEndActivity = () => {
  Alert.alert(
    'Stop Timer',
    'Are you sure you want to stop the timer?',
    [
      {
        text: 'Cancel',
        style: 'cancel',
      },
      {
        text: 'Stop',
        style: 'destructive',
        onPress: () => {
          TimeRecordWidget.endActivity();

          // βœ… Flash message
          showMessage({
            message: '⏹️ Timer stopped successfully',
            type: 'success',
            duration: 2000,
            autoHide: false,
            hideOnPress: true,
            icon: 'auto',
            floating: true,
          });
        },
      },
    ],
    { cancelable: true }
  );
};

βœ… Summary

StepWhat You Do
βœ… Create Swift fileTimeRecordWidget.swift with @objc methods
βœ… Create .m fileTimeRecordWidgetBridge.m for bridging
βœ… Add Widget targetCreate widget extension with Live Activity enabled
βœ… Define attributesCreate TimeRecoredWidgetAttributes for timer info
βœ… Connect React NativeCall methods from JS using NativeModules

🏁 Result

  • ⏱ A timer starts and shows live on Lock Screen and Dynamic Island
  • πŸ“… Shows started date like β€œJuly 16, 2025”
  • πŸ†” ID like "1823" visible in UI
  • πŸ›‘ Stoppable from React Native with success feedback

πŸ’‘ Tips

  • Use .timer style for live-updating text
  • Avoid updating too often for battery reasons
  • You can add support for pause, resume, or progress % next

πŸ”š Conclusion

With just a Swift-native bridge and one widget, you've built a full Live Activity system that works seamlessly with your React Native app. This gives your users a polished, real-time experience across iPhone’s Lock Screen and Dynamic Island.

Nο»Ώote : If Build issue

βš™οΈ 3. Enable Swift Support in Podfile

Ensure your Podfile includes this inside target 'YourApp' do:

use_frameworks! :linkage => :static

Then run:

cd ios && pod install