Build an Alarm App with AlarmKit: Schedules, Countdowns, and Dynamic Island


At Monsters, Inc., the most important thing isn’t the scream — it’s showing up on time. Imagine if Sulley overslept and missed his shift on the Scare Floor, or if Mike Wazowski forgot his comedy training session at the Laugh Factory. In iOS 26, Apple introduced AlarmKit — a framework that gives third-party apps access to system-level alarm behavior, the kind that breaks through Silent mode and Focus filters, just like the built-in Clock app. No more relying on local notifications that the user never hears.

In this tutorial, you’ll build Monsters, Inc. Shift Alarm — a complete alarm app for managing monster scare shift schedules. Along the way, you’ll learn how to create schedule-based alarms for recurring shifts, set countdown-based alarms for training sessions, bypass Silent mode with system-level alarm sounds, integrate the Dynamic Island to show active shift countdowns, and support Apple Watch for wrist-based alarms.

Prerequisites

Note: AlarmKit is a new framework introduced in iOS 26. It requires iOS 26 or later and is not available on earlier versions. All APIs in this tutorial are annotated with @available(iOS 26.0, *).

Contents

Getting Started

Let’s create the project and prepare the workspace for our Monsters, Inc. themed alarm app.

  1. Open Xcode and create a new project using the App template.
  2. Set the product name to MonstersShiftAlarm.
  3. Ensure the interface is SwiftUI and the language is Swift.
  4. Set the deployment target to iOS 26.0.
  5. Choose a team for code signing (required for AlarmKit entitlements).

After creating the project, your folder structure should look like this:

MonstersShiftAlarm/
├── MonstersShiftAlarmApp.swift
├── ContentView.swift
├── Assets.xcassets/
└── Preview Content/

We will add additional folders as we build: Models/, Managers/, Views/, and a widget extension for the Dynamic Island.

Step 1: Defining the Scare Floor Data Model

Every good app starts with a solid data model. Our alarm app manages two types of alarms: shift alarms (schedule-based, recurring) and training alarms (countdown-based, one-shot). Both are themed around the Monsters, Inc. universe.

Create a new file at Models/MonsterShift.swift:

import Foundation

/// Represents a scare floor shift at Monsters, Inc.
struct MonsterShift: Identifiable, Codable {
    let id: UUID
    var name: String
    var monster: MonsterEmployee
    var shiftTime: DateComponents
    var isEnabled: Bool
    var repeatingDays: Set<Weekday>
    var alarmIdentifier: String?

    init(
        id: UUID = UUID(),
        name: String,
        monster: MonsterEmployee,
        shiftTime: DateComponents,
        isEnabled: Bool = true,
        repeatingDays: Set<Weekday> = [],
        alarmIdentifier: String? = nil
    ) {
        self.id = id
        self.name = name
        self.monster = monster
        self.shiftTime = shiftTime
        self.isEnabled = isEnabled
        self.repeatingDays = repeatingDays
        self.alarmIdentifier = alarmIdentifier
    }
}

Now define the monster employees and weekday types:

/// A monster employee at Monsters, Inc.
enum MonsterEmployee: String, CaseIterable,
    Identifiable, Codable {

    case sulley = "James P. Sullivan"
    case mike = "Mike Wazowski"
    case randall = "Randall Boggs"
    case celia = "Celia Mae"
    case roz = "Roz"
    case fungus = "Fungus"

    var id: String { rawValue }

    var nickname: String {
        switch self {
        case .sulley: return "Sulley"
        case .mike: return "Mike"
        case .randall: return "Randall"
        case .celia: return "Celia"
        case .roz: return "Roz"
        case .fungus: return "Fungus"
        }
    }

    var icon: String {
        switch self {
        case .sulley: return "pawprint.fill"
        case .mike: return "eye.fill"
        case .randall: return "eye.slash.fill"
        case .celia: return "heart.fill"
        case .roz: return "doc.text.fill"
        case .fungus: return "flask.fill"
        }
    }

    var role: String {
        switch self {
        case .sulley: return "Top Scarer"
        case .mike: return "Scare Coach"
        case .randall: return "Rival Scarer"
        case .celia: return "Receptionist"
        case .roz: return "CDA Administrator"
        case .fungus: return "Lab Assistant"
        }
    }
}

/// Days of the week for repeating alarms.
enum Weekday: Int, CaseIterable, Identifiable, Codable {
    case sunday = 1, monday, tuesday, wednesday
    case thursday, friday, saturday

    var id: Int { rawValue }

    var abbreviation: String {
        switch self {
        case .sunday: return "Sun"
        case .monday: return "Mon"
        case .tuesday: return "Tue"
        case .wednesday: return "Wed"
        case .thursday: return "Thu"
        case .friday: return "Fri"
        case .saturday: return "Sat"
        }
    }
}

Next, create the training timer model at Models/TrainingTimer.swift:

import Foundation

/// A countdown-based training alarm for monster skill sessions.
struct TrainingTimer: Identifiable, Codable {
    let id: UUID
    var name: String
    var monster: MonsterEmployee
    var durationInSeconds: Int
    var alarmIdentifier: String?

    init(
        id: UUID = UUID(),
        name: String,
        monster: MonsterEmployee,
        durationInSeconds: Int,
        alarmIdentifier: String? = nil
    ) {
        self.id = id
        self.name = name
        self.monster = monster
        self.durationInSeconds = durationInSeconds
        self.alarmIdentifier = alarmIdentifier
    }

    var formattedDuration: String {
        let minutes = durationInSeconds / 60
        let seconds = durationInSeconds % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }
}

We separate shift alarms (schedule-based) from training timers (countdown-based) because AlarmKit treats these as fundamentally different alarm paradigms. Schedule-based alarms fire at a specific time, while countdown-based alarms fire after a duration elapses.

Checkpoint: Build your project to verify the models compile without errors. There is nothing visible yet, but you should see zero warnings and zero errors in the build log. If you see issues, verify that all enums conform to Codable and Identifiable as shown above.

Step 2: Configuring AlarmKit Capabilities

AlarmKit requires specific entitlements to schedule system-level alarms. Unlike local notifications, AlarmKit alarms can break through Silent mode and Focus filters — this elevated privilege requires explicit capability configuration.

  1. Select your project target in Xcode.
  2. Navigate to Signing & Capabilities.
  3. Click + Capability and add AlarmKit.
  4. In the capability configuration, ensure Alarm Scheduling is enabled.

Next, add the alarm usage description to your Info.plist:

KeyValue
NSAlarmUsageDescription”Monsters, Inc. Shift Alarm schedules alarms for shifts”

This string tells the user why your app needs alarm permissions. Be transparent about Silent mode bypass — users should understand that your alarms will sound regardless of their ringer switch position.

Apple Docs: AlarmKit — Apple Developer Documentation

Step 3: Building the Alarm Manager

The alarm manager coordinates all interactions with AlarmKit. It handles creating, scheduling, canceling, and persisting alarms. We use the AlarmManager singleton provided by AlarmKit to interface with the system.

Create Managers/ShiftAlarmManager.swift:

import AlarmKit
import Observation
import Foundation

@available(iOS 26.0, *)
@Observable
final class ShiftAlarmManager {
    // MARK: - State
    var shifts: [MonsterShift] = []
    var trainingTimers: [TrainingTimer] = []
    var isAuthorized = false
    var authorizationError: String?

    // MARK: - Persistence Keys
    private let shiftsKey = "monster_shifts"
    private let timersKey = "training_timers"

    init() {
        loadSavedData()
    }

    // MARK: - Authorization

    /// Requests permission to schedule alarms.
    func requestAuthorization() async {
        do {
            let status = try await
                AlarmManager.shared.requestAuthorization()
            isAuthorized = (status == .authorized)
        } catch {
            authorizationError = "Alarm authorization failed: "
                + "\(error.localizedDescription)"
            isAuthorized = false
        }
    }
}

Add persistence methods to save and load alarm data:

extension ShiftAlarmManager {
    // MARK: - Persistence

    /// Saves shifts and timers to UserDefaults.
    func saveData() {
        if let shiftsData = try? JSONEncoder().encode(shifts) {
            UserDefaults.standard.set(
                shiftsData, forKey: shiftsKey
            )
        }
        if let timersData = try? JSONEncoder()
            .encode(trainingTimers) {
            UserDefaults.standard.set(
                timersData, forKey: timersKey
            )
        }
    }

    /// Loads saved shifts and timers from UserDefaults.
    private func loadSavedData() {
        if let data = UserDefaults.standard
            .data(forKey: shiftsKey),
           let decoded = try? JSONDecoder().decode(
               [MonsterShift].self, from: data
           ) {
            shifts = decoded
        }
        if let data = UserDefaults.standard
            .data(forKey: timersKey),
           let decoded = try? JSONDecoder().decode(
               [TrainingTimer].self, from: data
           ) {
            trainingTimers = decoded
        }
    }
}

Tip: For a production app, consider using SwiftData or Core Data instead of UserDefaults for alarm persistence. UserDefaults works well for small datasets like this tutorial, but structured storage is better for apps with many alarms and complex relationships.

Step 4: Creating Schedule-Based Shift Alarms

Schedule-based alarms fire at a specific time on specific days — perfect for recurring scare floor shifts. Sulley’s morning shift starts at 7:00 AM every weekday, and the alarm should reliably wake him up even if his phone is on Silent mode.

Add these methods to ShiftAlarmManager:

// MARK: - Schedule-Based Alarms

/// Schedules a recurring shift alarm using AlarmKit.
func scheduleShiftAlarm(
    for shift: MonsterShift
) async throws {
    var alarmConfig = AlarmConfiguration(
        identifier: shift.id.uuidString,
        title: shift.name,
        body: "\(shift.monster.nickname)'s shift starts "
            + "soon — time to hit the Scare Floor!",
        scheduledTime: shift.shiftTime
    )

    // Configure repeating days
    if !shift.repeatingDays.isEmpty {
        let weekdays = shift.repeatingDays
            .map { $0.rawValue }
        alarmConfig.recurrence = AlarmRecurrence(
            weekdays: weekdays
        )
    }

    // Set the alarm sound
    alarmConfig.sound = .default

    try await AlarmManager.shared.schedule(alarmConfig)

    // Update the shift with its alarm identifier
    if let index = shifts.firstIndex(
        where: { $0.id == shift.id }
    ) {
        shifts[index].alarmIdentifier = shift.id.uuidString
    }
    saveData()
}

/// Cancels a scheduled shift alarm.
func cancelShiftAlarm(
    for shift: MonsterShift
) async throws {
    guard let identifier = shift.alarmIdentifier
    else { return }

    try await AlarmManager.shared.cancel(
        alarmWithIdentifier: identifier
    )

    if let index = shifts.firstIndex(
        where: { $0.id == shift.id }
    ) {
        shifts[index].alarmIdentifier = nil
        shifts[index].isEnabled = false
    }
    saveData()
}

/// Toggles a shift alarm on or off.
func toggleShiftAlarm(
    _ shift: MonsterShift
) async throws {
    if shift.isEnabled {
        try await cancelShiftAlarm(for: shift)
    } else {
        var updatedShift = shift
        updatedShift.isEnabled = true
        if let index = shifts.firstIndex(
            where: { $0.id == shift.id }
        ) {
            shifts[index].isEnabled = true
        }
        try await scheduleShiftAlarm(for: updatedShift)
    }
}

The AlarmConfiguration struct is the core of AlarmKit scheduling. It takes a unique identifier, a title and body for the alarm notification, the scheduled time as DateComponents, and an optional recurrence pattern. The AlarmRecurrence type accepts an array of weekday integers (1 = Sunday through 7 = Saturday), matching Foundation’s DateComponents weekday convention.

Add a helper method to create default shifts for new users:

/// Creates a set of default Monsters, Inc. shifts.
func createDefaultShifts() {
    let defaults: [(
        String, MonsterEmployee, Int, Int, Set<Weekday>
    )] = [
        ("Morning Scare Shift", .sulley, 7, 0,
         [.monday, .tuesday, .wednesday,
          .thursday, .friday]),
        ("Comedy Training", .mike, 9, 30,
         [.monday, .wednesday, .friday]),
        ("Night Stealth Shift", .randall, 22, 0,
         [.tuesday, .thursday]),
        ("Front Desk Opening", .celia, 8, 0,
         [.monday, .tuesday, .wednesday,
          .thursday, .friday]),
        ("Paperwork Review", .roz, 14, 0,
         [.friday]),
    ]

    shifts = defaults.map {
        name, monster, hour, minute, days in
        MonsterShift(
            name: name,
            monster: monster,
            shiftTime: DateComponents(
                hour: hour, minute: minute
            ),
            repeatingDays: days
        )
    }
    saveData()
}

Checkpoint: Build the project. There are no visible changes yet, but the alarm scheduling logic should compile without errors. If you see issues with AlarmManager.shared, verify that you added the AlarmKit capability in Step 2 and that your deployment target is iOS 26.0 or later.

Step 5: Building the Shift Alarm List View

Now let’s build the primary interface — a list of scare floor shifts, each with a toggle to enable or disable its alarm.

Create Views/ShiftListView.swift:

import SwiftUI

@available(iOS 26.0, *)
struct ShiftListView: View {
    let manager: ShiftAlarmManager

    @State private var showingAddShift = false

    var body: some View {
        NavigationStack {
            Group {
                if manager.shifts.isEmpty {
                    emptyState
                } else {
                    shiftList
                }
            }
            .navigationTitle("Scare Floor Shifts")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showingAddShift = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }

                ToolbarItem(placement: .topBarLeading) {
                    if manager.shifts.isEmpty {
                        Button("Load Defaults") {
                            manager.createDefaultShifts()
                        }
                    }
                }
            }
            .sheet(isPresented: $showingAddShift) {
                AddShiftView(manager: manager)
            }
        }
    }
}

Add the subviews:

@available(iOS 26.0, *)
extension ShiftListView {
    private var emptyState: some View {
        ContentUnavailableView {
            Label("No Shifts Scheduled",
                  systemImage: "alarm")
        } description: {
            Text("The Scare Floor is empty. Add shifts for "
                + "Sulley, Mike, and the rest of the crew, "
                + "or tap \"Load Defaults\" to get started.")
        }
    }

    private var shiftList: some View {
        List {
            ForEach(manager.shifts) { shift in
                ShiftRow(shift: shift) {
                    Task {
                        try? await manager
                            .toggleShiftAlarm(shift)
                    }
                }
            }
            .onDelete { indexSet in
                Task {
                    for index in indexSet {
                        let shift = manager.shifts[index]
                        try? await manager
                            .cancelShiftAlarm(for: shift)
                    }
                    manager.shifts.remove(atOffsets: indexSet)
                    manager.saveData()
                }
            }
        }
    }
}

Create the individual row component at Views/ShiftRow.swift:

import SwiftUI

struct ShiftRow: View {
    let shift: MonsterShift
    let onToggle: () -> Void

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: shift.monster.icon)
                .font(.title2)
                .foregroundStyle(.purple)
                .frame(width: 36)

            VStack(alignment: .leading, spacing: 4) {
                Text(shift.name)
                    .font(.headline)

                Text("\(shift.monster.nickname) — "
                    + "\(shift.monster.role)")
                    .font(.caption)
                    .foregroundStyle(.secondary)

                if !shift.repeatingDays.isEmpty {
                    Text(formattedDays)
                        .font(.caption2)
                        .foregroundStyle(.purple)
                }
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 4) {
                Text(formattedTime)
                    .font(.title2)
                    .fontWeight(.light)
                    .fontDesign(.monospaced)

                Toggle("", isOn: .init(
                    get: { shift.isEnabled },
                    set: { _ in onToggle() }
                ))
                .labelsHidden()
                .tint(.purple)
            }
        }
        .padding(.vertical, 4)
    }

    private var formattedTime: String {
        let hour = shift.shiftTime.hour ?? 0
        let minute = shift.shiftTime.minute ?? 0
        let formatter = DateFormatter()
        formatter.dateFormat = "h:mm a"
        let date = Calendar.current.date(
            from: DateComponents(
                hour: hour, minute: minute
            )
        ) ?? Date()
        return formatter.string(from: date)
    }

    private var formattedDays: String {
        let sorted = shift.repeatingDays
            .sorted { $0.rawValue < $1.rawValue }
        return sorted.map(\.abbreviation)
            .joined(separator: ", ")
    }
}

Now create the add-shift form at Views/AddShiftView.swift:

import SwiftUI

@available(iOS 26.0, *)
struct AddShiftView: View {
    let manager: ShiftAlarmManager
    @Environment(\.dismiss) private var dismiss

    @State private var shiftName = ""
    @State private var selectedMonster: MonsterEmployee =
        .sulley
    @State private var alarmTime = Date()
    @State private var selectedDays: Set<Weekday> = []

    var body: some View {
        NavigationStack {
            Form {
                Section("Shift Details") {
                    TextField("Shift Name", text: $shiftName)
                        .textInputAutocapitalization(.words)

                    Picker("Monster",
                           selection: $selectedMonster) {
                        ForEach(MonsterEmployee.allCases) {
                            monster in
                            Label(monster.nickname,
                                  systemImage: monster.icon)
                                .tag(monster)
                        }
                    }
                }

                Section("Alarm Time") {
                    DatePicker(
                        "Time",
                        selection: $alarmTime,
                        displayedComponents: .hourAndMinute
                    )
                }

                Section("Repeat Days") {
                    ForEach(Weekday.allCases) { day in
                        Toggle(day.abbreviation, isOn: .init(
                            get: {
                                selectedDays.contains(day)
                            },
                            set: { isOn in
                                if isOn {
                                    selectedDays.insert(day)
                                } else {
                                    selectedDays.remove(day)
                                }
                            }
                        ))
                    }
                }
            }
            .navigationTitle("New Shift Alarm")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        saveShift()
                    }
                    .disabled(shiftName.isEmpty)
                }
            }
        }
    }

    private func saveShift() {
        let components = Calendar.current.dateComponents(
            [.hour, .minute], from: alarmTime
        )

        let shift = MonsterShift(
            name: shiftName,
            monster: selectedMonster,
            shiftTime: components,
            repeatingDays: selectedDays
        )

        manager.shifts.append(shift)

        Task {
            try? await manager.scheduleShiftAlarm(for: shift)
        }

        dismiss()
    }
}

Checkpoint: Build and run. You should see the “Scare Floor Shifts” screen. Tap “Load Defaults” to populate five pre-configured shifts — Sulley’s Morning Scare Shift at 7:00 AM (Mon-Fri), Mike’s Comedy Training at 9:30 AM, Randall’s Night Stealth Shift at 10:00 PM, Celia’s Front Desk Opening at 8:00 AM, and Roz’s Paperwork Review at 2:00 PM on Fridays. Each row shows the monster’s icon, shift name, role, time, and a toggle. Toggling a shift on should schedule the alarm through AlarmKit. Tap the ”+” button to verify the add-shift form appears with monster selection, time picker, and day toggles.

Step 6: Creating Countdown-Based Training Alarms

Not all alarms are time-of-day based. Monster training sessions need countdown timers — “Start a 15-minute scare technique drill and alert me when it’s done.” AlarmKit’s countdown paradigm is perfect for this.

Add countdown alarm methods to ShiftAlarmManager:

// MARK: - Countdown-Based Alarms

/// Schedules a countdown alarm for a training session.
func scheduleTrainingAlarm(
    for timer: TrainingTimer
) async throws {
    let config = AlarmConfiguration(
        identifier: timer.id.uuidString,
        title: "\(timer.name) Complete",
        body: "\(timer.monster.nickname)'s training session "
            + "is over. Great work on the Scare Floor!",
        countdownDuration: TimeInterval(
            timer.durationInSeconds
        )
    )

    try await AlarmManager.shared.schedule(config)

    if let index = trainingTimers.firstIndex(
        where: { $0.id == timer.id }
    ) {
        trainingTimers[index].alarmIdentifier =
            timer.id.uuidString
    }
    saveData()
}

/// Cancels an active training countdown.
func cancelTrainingAlarm(
    for timer: TrainingTimer
) async throws {
    guard let identifier = timer.alarmIdentifier
    else { return }
    try await AlarmManager.shared.cancel(
        alarmWithIdentifier: identifier
    )

    if let index = trainingTimers.firstIndex(
        where: { $0.id == timer.id }
    ) {
        trainingTimers[index].alarmIdentifier = nil
    }
    saveData()
}

/// Creates default training presets.
func createDefaultTrainingTimers() {
    let presets: [(String, MonsterEmployee, Int)] = [
        ("Scare Technique Drill", .sulley, 900),
        ("Comedy Routine Practice", .mike, 600),
        ("Invisibility Training", .randall, 1200),
        ("CDA Inspection Prep", .roz, 300),
        ("Lab Experiment Cycle", .fungus, 1800),
    ]

    trainingTimers = presets.map {
        name, monster, duration in
        TrainingTimer(
            name: name,
            monster: monster,
            durationInSeconds: duration
        )
    }
    saveData()
}

The key difference from schedule-based alarms is the countdownDuration parameter. Instead of a scheduledTime with DateComponents, we pass a TimeInterval in seconds. AlarmKit starts counting down immediately when the alarm is scheduled and fires the alert when the duration elapses.

Step 7: Building the Training Timer View

Create the training timer interface at Views/TrainingTimerView.swift:

import SwiftUI

@available(iOS 26.0, *)
struct TrainingTimerView: View {
    let manager: ShiftAlarmManager

    @State private var activeTimerID: UUID?
    @State private var remainingSeconds: Int = 0
    @State private var countdownTimer: Timer?
    @State private var showingAddTimer = false

    var body: some View {
        NavigationStack {
            Group {
                if manager.trainingTimers.isEmpty {
                    emptyState
                } else {
                    timerList
                }
            }
            .navigationTitle("Monster Training")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showingAddTimer = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
                ToolbarItem(placement: .topBarLeading) {
                    if manager.trainingTimers.isEmpty {
                        Button("Load Presets") {
                            manager
                                .createDefaultTrainingTimers()
                        }
                    }
                }
            }
            .sheet(isPresented: $showingAddTimer) {
                AddTrainingTimerView(manager: manager)
            }
        }
    }
}

Add the subviews:

@available(iOS 26.0, *)
extension TrainingTimerView {
    private var emptyState: some View {
        ContentUnavailableView {
            Label("No Training Timers",
                  systemImage: "timer")
        } description: {
            Text("Set up countdown timers for monster "
                + "training sessions. Tap \"Load Presets\" "
                + "for Sulley, Mike, and the crew.")
        }
    }

    private var timerList: some View {
        List(manager.trainingTimers) { timer in
            TrainingTimerRow(
                timer: timer,
                isActive: activeTimerID == timer.id,
                remainingSeconds: activeTimerID == timer.id
                    ? remainingSeconds
                    : timer.durationInSeconds,
                onStart: { startTimer(timer) },
                onStop: { stopTimer(timer) }
            )
        }
    }

    private func startTimer(_ timer: TrainingTimer) {
        // Cancel any running timer
        countdownTimer?.invalidate()

        activeTimerID = timer.id
        remainingSeconds = timer.durationInSeconds

        // Schedule the AlarmKit alarm
        Task {
            try? await manager
                .scheduleTrainingAlarm(for: timer)
        }

        // Start a local countdown for the UI
        countdownTimer = Timer.scheduledTimer(
            withTimeInterval: 1, repeats: true
        ) { _ in
            if remainingSeconds > 0 {
                remainingSeconds -= 1
            } else {
                countdownTimer?.invalidate()
                activeTimerID = nil
            }
        }
    }

    private func stopTimer(_ timer: TrainingTimer) {
        countdownTimer?.invalidate()
        countdownTimer = nil
        activeTimerID = nil

        Task {
            try? await manager
                .cancelTrainingAlarm(for: timer)
        }
    }
}

Create the row component at Views/TrainingTimerRow.swift:

import SwiftUI

struct TrainingTimerRow: View {
    let timer: TrainingTimer
    let isActive: Bool
    let remainingSeconds: Int
    let onStart: () -> Void
    let onStop: () -> Void

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: timer.monster.icon)
                .font(.title2)
                .foregroundStyle(
                    isActive ? .green : .purple
                )
                .frame(width: 36)

            VStack(alignment: .leading, spacing: 4) {
                Text(timer.name)
                    .font(.headline)

                Text(timer.monster.nickname)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 8) {
                Text(formattedRemaining)
                    .font(.title3)
                    .fontWeight(.medium)
                    .fontDesign(.monospaced)
                    .foregroundStyle(
                        isActive ? .green : .primary
                    )

                Button {
                    if isActive {
                        onStop()
                    } else {
                        onStart()
                    }
                } label: {
                    Image(systemName: isActive
                        ? "stop.fill" : "play.fill")
                        .foregroundStyle(
                            isActive ? .red : .green
                        )
                }
                .buttonStyle(.bordered)
            }
        }
        .padding(.vertical, 4)
    }

    private var formattedRemaining: String {
        let minutes = remainingSeconds / 60
        let seconds = remainingSeconds % 60
        return String(
            format: "%02d:%02d", minutes, seconds
        )
    }
}

Now create the add-timer form at Views/AddTrainingTimerView.swift:

import SwiftUI

@available(iOS 26.0, *)
struct AddTrainingTimerView: View {
    let manager: ShiftAlarmManager
    @Environment(\.dismiss) private var dismiss

    @State private var timerName = ""
    @State private var selectedMonster: MonsterEmployee =
        .sulley
    @State private var minutes: Int = 10

    var body: some View {
        NavigationStack {
            Form {
                Section("Training Details") {
                    TextField("Session Name",
                              text: $timerName)
                    Picker("Monster",
                           selection: $selectedMonster) {
                        ForEach(MonsterEmployee.allCases) {
                            monster in
                            Label(monster.nickname,
                                  systemImage: monster.icon)
                                .tag(monster)
                        }
                    }
                }

                Section("Duration") {
                    Stepper(
                        "\(minutes) minutes",
                        value: $minutes,
                        in: 1...120
                    )
                }
            }
            .navigationTitle("New Training Timer")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        let timer = TrainingTimer(
                            name: timerName,
                            monster: selectedMonster,
                            durationInSeconds: minutes * 60
                        )
                        manager.trainingTimers.append(timer)
                        manager.saveData()
                        dismiss()
                    }
                    .disabled(timerName.isEmpty)
                }
            }
        }
    }
}

Checkpoint: Build and run. Navigate to the Monster Training tab and tap “Load Presets.” You should see five training timers: Sulley’s 15-minute Scare Technique Drill, Mike’s 10-minute Comedy Routine Practice, Randall’s 20-minute Invisibility Training, Roz’s 5-minute CDA Inspection Prep, and Fungus’s 30-minute Lab Experiment Cycle. Tap the play button next to “Comedy Routine Practice.” The countdown should start at 10:00 and tick down second by second with the icon turning green. Tap stop to cancel. The AlarmKit alarm fires at countdown completion even if the app is backgrounded — that is the power of system-level alarms.

Step 8: Configuring Silent Mode Bypass

The defining feature of AlarmKit is Silent mode bypass. Unlike UNNotificationRequest, which respects the ringer switch, AlarmKit alarms play their sound at full volume even when the device is silenced. This is critical for a shift alarm app — Sulley cannot afford to miss his morning scare shift because he left his phone on Silent.

AlarmKit handles Silent mode bypass automatically when you use AlarmConfiguration. However, you can customize the alarm sound and behavior.

Add a sound configuration method to ShiftAlarmManager:

// MARK: - Alarm Sound Configuration

/// Creates an alarm configuration with custom sound settings.
func createAlarmConfig(
    identifier: String,
    title: String,
    body: String,
    scheduledTime: DateComponents? = nil,
    countdownDuration: TimeInterval? = nil,
    isCritical: Bool = true
) -> AlarmConfiguration {
    var config: AlarmConfiguration

    if let scheduledTime {
        config = AlarmConfiguration(
            identifier: identifier,
            title: title,
            body: body,
            scheduledTime: scheduledTime
        )
    } else if let countdownDuration {
        config = AlarmConfiguration(
            identifier: identifier,
            title: title,
            body: body,
            countdownDuration: countdownDuration
        )
    } else {
        fatalError(
            "Either scheduledTime or countdownDuration "
            + "must be provided"
        )
    }

    // AlarmKit alarms always bypass Silent mode by default.
    // The sound plays at the system alarm volume.
    config.sound = .default

    // Critical alarms also bypass Focus filters.
    if isCritical {
        config.interruptionLevel = .critical
    }

    return config
}

The interruptionLevel property controls how aggressively the alarm interrupts the user:

LevelBehavior
.activeStandard — bypasses Silent mode but respects Focus
.timeSensitiveBypasses Silent mode and most Focus filters
.criticalBypasses Silent mode, Focus filters, and Do Not Dist.

Warning: The .critical interruption level requires a special entitlement from Apple. For most alarm apps, .timeSensitive is sufficient and does not require additional approval. Only use .critical for genuinely critical alarms like medication reminders or emergency alerts. Roz from the CDA would approve of this level of caution. See AlarmConfiguration in the Apple documentation for the full API reference.

Step 9: Adding Dynamic Island Integration

When a countdown training timer is running, we want to show progress on the Dynamic Island. This gives monsters a glanceable view of their remaining training time without opening the app — Sulley can check his Lock Screen while training on the Scare Floor.

Dynamic Island integration requires a Widget Extension with ActivityKit. Let’s add one.

First, add a new target to your project:

  1. In Xcode, go to File > New > Target.
  2. Choose Widget Extension.
  3. Name it ShiftAlarmWidgets.
  4. Uncheck “Include Configuration App Intent” (we will manage state manually).
  5. Click Finish and activate the scheme when prompted.

Create the Live Activity attributes. In your main app target, create Models/ShiftAlarmActivity.swift:

import ActivityKit
import Foundation

struct ShiftAlarmAttributes: ActivityAttributes {
    /// The monster running the training session.
    let monsterName: String
    let trainingName: String
    let monsterIcon: String

    struct ContentState: Codable, Hashable {
        let remainingSeconds: Int
        let totalSeconds: Int

        var progress: Double {
            guard totalSeconds > 0 else { return 0 }
            return 1.0 - (
                Double(remainingSeconds)
                / Double(totalSeconds)
            )
        }

        var formattedRemaining: String {
            let minutes = remainingSeconds / 60
            let seconds = remainingSeconds % 60
            return String(
                format: "%02d:%02d", minutes, seconds
            )
        }
    }
}

Now implement the widget views in the ShiftAlarmWidgets target. Open or create ShiftAlarmWidgets/ShiftAlarmLiveActivity.swift:

import ActivityKit
import SwiftUI
import WidgetKit

struct ShiftAlarmLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(
            for: ShiftAlarmAttributes.self
        ) { context in
            // Lock Screen / Banner presentation
            lockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded view
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName:
                        context.attributes.monsterIcon)
                        .font(.title2)
                        .foregroundStyle(.purple)
                }
                DynamicIslandExpandedRegion(.center) {
                    VStack(spacing: 4) {
                        Text(context.attributes
                            .trainingName)
                            .font(.headline)
                            .lineLimit(1)
                        Text(context.state
                            .formattedRemaining)
                            .font(.system(
                                .title, design: .monospaced
                            ))
                            .fontWeight(.bold)
                    }
                }
                DynamicIslandExpandedRegion(.trailing) {
                    ProgressView(
                        value: context.state.progress
                    )
                    .progressViewStyle(.circular)
                    .tint(.purple)
                }
            } compactLeading: {
                Image(systemName:
                    context.attributes.monsterIcon)
                    .foregroundStyle(.purple)
            } compactTrailing: {
                Text(context.state.formattedRemaining)
                    .font(.caption)
                    .fontDesign(.monospaced)
                    .foregroundStyle(.purple)
            } minimal: {
                Image(systemName: "timer")
                    .foregroundStyle(.purple)
            }
        }
    }

    private func lockScreenView(
        context: ActivityViewContext<ShiftAlarmAttributes>
    ) -> some View {
        HStack(spacing: 16) {
            Image(systemName:
                context.attributes.monsterIcon)
                .font(.largeTitle)
                .foregroundStyle(.purple)

            VStack(alignment: .leading, spacing: 4) {
                Text(context.attributes.monsterName)
                    .font(.headline)
                Text(context.attributes.trainingName)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 4) {
                Text(context.state.formattedRemaining)
                    .font(.system(
                        .title2, design: .monospaced
                    ))
                    .fontWeight(.bold)

                ProgressView(
                    value: context.state.progress
                )
                .tint(.purple)
                .frame(width: 60)
            }
        }
        .padding()
        .background(.ultraThinMaterial)
    }
}

Now add Live Activity management to ShiftAlarmManager. Back in the main target, add:

import ActivityKit

extension ShiftAlarmManager {
    // MARK: - Live Activity Management

    /// Starts a Dynamic Island Live Activity for a
    /// training timer.
    func startLiveActivity(
        for timer: TrainingTimer
    ) throws -> Activity<ShiftAlarmAttributes>? {
        guard ActivityAuthorizationInfo()
            .areActivitiesEnabled else {
            return nil
        }

        let attributes = ShiftAlarmAttributes(
            monsterName: timer.monster.nickname,
            trainingName: timer.name,
            monsterIcon: timer.monster.icon
        )

        let initialState = ShiftAlarmAttributes
            .ContentState(
                remainingSeconds: timer.durationInSeconds,
                totalSeconds: timer.durationInSeconds
            )

        let content = ActivityContent(
            state: initialState,
            staleDate: Date().addingTimeInterval(
                TimeInterval(timer.durationInSeconds)
            )
        )

        return try Activity.request(
            attributes: attributes,
            content: content,
            pushType: nil
        )
    }

    /// Updates the Live Activity with the current
    /// remaining time.
    func updateLiveActivity(
        _ activity: Activity<ShiftAlarmAttributes>,
        remainingSeconds: Int,
        totalSeconds: Int
    ) async {
        let state = ShiftAlarmAttributes.ContentState(
            remainingSeconds: remainingSeconds,
            totalSeconds: totalSeconds
        )
        let content = ActivityContent(
            state: state,
            staleDate: nil
        )
        await activity.update(content)
    }

    /// Ends the Live Activity when the timer completes
    /// or is canceled.
    func endLiveActivity(
        _ activity: Activity<ShiftAlarmAttributes>
    ) async {
        let finalState = ShiftAlarmAttributes
            .ContentState(
                remainingSeconds: 0,
                totalSeconds: 0
            )
        let content = ActivityContent(
            state: finalState,
            staleDate: nil
        )
        await activity.end(
            content, dismissalPolicy: .immediate
        )
    }
}

Update the TrainingTimerView to manage the Live Activity alongside the countdown. Open Views/TrainingTimerView.swift and add a state property, then modify the startTimer and stopTimer methods:

@State private var currentActivity:
    Activity<ShiftAlarmAttributes>?

private func startTimer(_ timer: TrainingTimer) {
    countdownTimer?.invalidate()
    activeTimerID = timer.id
    remainingSeconds = timer.durationInSeconds

    // Start the Live Activity for Dynamic Island
    currentActivity = try? manager
        .startLiveActivity(for: timer)

    // Schedule the AlarmKit alarm
    Task {
        try? await manager
            .scheduleTrainingAlarm(for: timer)
    }

    countdownTimer = Timer.scheduledTimer(
        withTimeInterval: 1, repeats: true
    ) { _ in
        if remainingSeconds > 0 {
            remainingSeconds -= 1
            // Update the Dynamic Island every second
            if let activity = currentActivity {
                Task {
                    await manager.updateLiveActivity(
                        activity,
                        remainingSeconds: remainingSeconds,
                        totalSeconds:
                            timer.durationInSeconds
                    )
                }
            }
        } else {
            countdownTimer?.invalidate()
            if let activity = currentActivity {
                Task {
                    await manager
                        .endLiveActivity(activity)
                }
            }
            activeTimerID = nil
            currentActivity = nil
        }
    }
}

private func stopTimer(_ timer: TrainingTimer) {
    countdownTimer?.invalidate()
    countdownTimer = nil
    activeTimerID = nil

    if let activity = currentActivity {
        Task {
            await manager.endLiveActivity(activity)
        }
        currentActivity = nil
    }

    Task {
        try? await manager
            .cancelTrainingAlarm(for: timer)
    }
}

Checkpoint: Build and run on a physical device. Navigate to Monster Training, load the presets, and start Sulley’s “Scare Technique Drill” timer. Minimize the app — you should see the Dynamic Island compact view showing Sulley’s paw icon and the countdown in monospaced font. Long-press the Dynamic Island to expand it, revealing the full training name, a circular progress indicator, and the remaining time. The countdown ticks down in real time. When the timer completes, the Dynamic Island dismisses and the AlarmKit alarm sounds — even if your phone is on Silent mode.

Step 10: Adding Apple Watch Support

Monsters need their alarms on the wrist too. AlarmKit supports watchOS, which means we can mirror shift alarms to the Apple Watch for haptic wake-ups. This step adds a basic WatchKit companion target.

  1. In Xcode, go to File > New > Target.
  2. Choose Watch App for iOS App (under watchOS).
  3. Name it MonstersShiftAlarm Watch App.
  4. Ensure it is embedded in the main iOS app.

Create a shared alarm data file that both the iOS and watchOS targets can access. Add Shared/SharedAlarmData.swift to both targets:

import Foundation

/// Shared alarm data accessible from both iOS and watchOS.
struct SharedAlarmData: Codable {
    var shifts: [MonsterShift]
    var trainingTimers: [TrainingTimer]
}

/// Manages syncing alarm data between iOS and watchOS
/// via App Groups.
enum AlarmDataSync {
    static let suiteName =
        "group.com.monstersshiftalarm.shared"
    static let dataKey = "shared_alarm_data"

    static func save(_ data: SharedAlarmData) {
        guard let defaults = UserDefaults(
            suiteName: suiteName
        ),
        let encoded = try? JSONEncoder().encode(data)
        else { return }
        defaults.set(encoded, forKey: dataKey)
    }

    static func load() -> SharedAlarmData? {
        guard let defaults = UserDefaults(
            suiteName: suiteName
        ),
        let data = defaults.data(forKey: dataKey),
        let decoded = try? JSONDecoder().decode(
            SharedAlarmData.self, from: data
        ) else { return nil }
        return decoded
    }
}

Add App Group capabilities to both the iOS and watchOS targets:

  1. Select each target in Xcode.
  2. Add the App Groups capability.
  3. Add the group identifier: group.com.monstersshiftalarm.shared.

In the watchOS app, create a simple alarm list view at MonstersShiftAlarm Watch App/WatchShiftListView.swift:

import SwiftUI
import AlarmKit

@available(watchOS 12.0, *)
struct WatchShiftListView: View {
    @State private var shifts: [MonsterShift] = []

    var body: some View {
        NavigationStack {
            if shifts.isEmpty {
                ContentUnavailableView(
                    "No Shifts",
                    systemImage: "alarm",
                    description: Text(
                        "Open the iPhone app to set up "
                        + "shifts."
                    )
                )
            } else {
                List(shifts) { shift in
                    HStack {
                        Image(systemName:
                            shift.monster.icon)
                            .foregroundStyle(.purple)
                        VStack(alignment: .leading) {
                            Text(shift.name)
                                .font(.headline)
                            Text(shift.monster.nickname)
                                .font(.caption2)
                        }
                        Spacer()
                        Text(formattedTime(for: shift))
                            .font(.caption)
                            .fontDesign(.monospaced)
                    }
                }
            }
        }
        .navigationTitle("Shifts")
        .onAppear(perform: loadShifts)
    }

    private func loadShifts() {
        if let data = AlarmDataSync.load() {
            shifts = data.shifts.filter(\.isEnabled)
        }
    }

    private func formattedTime(
        for shift: MonsterShift
    ) -> String {
        let hour = shift.shiftTime.hour ?? 0
        let minute = shift.shiftTime.minute ?? 0
        return String(
            format: "%d:%02d", hour, minute
        )
    }
}

Update ShiftAlarmManager to sync data when alarms are modified. Add a call to syncToWatch() at the end of saveData():

extension ShiftAlarmManager {
    /// Syncs current alarm data to the shared App Group
    /// for watchOS access.
    func syncToWatch() {
        let sharedData = SharedAlarmData(
            shifts: shifts,
            trainingTimers: trainingTimers
        )
        AlarmDataSync.save(sharedData)
    }
}

And update the saveData() method to include the sync call:

func saveData() {
    if let shiftsData = try? JSONEncoder().encode(shifts) {
        UserDefaults.standard.set(
            shiftsData, forKey: shiftsKey
        )
    }
    if let timersData = try? JSONEncoder()
        .encode(trainingTimers) {
        UserDefaults.standard.set(
            timersData, forKey: timersKey
        )
    }
    syncToWatch() // Sync to Apple Watch
}

Tip: For a production app, use WCSession from the WatchConnectivity framework for real-time bidirectional sync between iPhone and Apple Watch. The App Group approach shown here works for one-directional data sharing and is simpler to implement.

Step 11: Assembling the Final App

Let’s wire everything together with a polished tab-based interface and authorization flow.

Open ContentView.swift and replace its contents:

import SwiftUI

@available(iOS 26.0, *)
struct ContentView: View {
    @State private var manager = ShiftAlarmManager()
    @State private var hasAuthorized = false

    var body: some View {
        if hasAuthorized {
            mainApp
        } else {
            authorizationView
        }
    }

    private var authorizationView: some View {
        VStack(spacing: 24) {
            Image(systemName:
                "alarm.waves.left.and.right.fill")
                .font(.system(size: 80))
                .foregroundStyle(.purple)

            Text("Monsters, Inc.\nShift Alarm")
                .font(.largeTitle)
                .fontWeight(.bold)
                .multilineTextAlignment(.center)

            Text("Never miss a scare shift again. We need "
                + "alarm permissions to ensure your shifts "
                + "ring through Silent mode and Focus "
                + "filters — Roz's orders.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)
                .padding(.horizontal)

            if let error = manager.authorizationError {
                Text(error)
                    .foregroundStyle(.red)
                    .font(.caption)
            }

            Button {
                Task {
                    await manager.requestAuthorization()
                    if manager.isAuthorized {
                        hasAuthorized = true
                    }
                }
            } label: {
                Label("Enable Alarms",
                      systemImage: "alarm.fill")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.borderedProminent)
            .tint(.purple)
            .padding(.horizontal, 40)
        }
        .padding()
    }

    private var mainApp: some View {
        TabView {
            Tab("Shifts", systemImage: "alarm.fill") {
                ShiftListView(manager: manager)
            }

            Tab("Training", systemImage: "timer") {
                TrainingTimerView(manager: manager)
            }
        }
        .tint(.purple)
    }
}

Update the app entry point in MonstersShiftAlarmApp.swift:

import SwiftUI

@main
struct MonstersShiftAlarmApp: App {
    var body: some Scene {
        WindowGroup {
            if #available(iOS 26.0, *) {
                ContentView()
            } else {
                Text("Monsters, Inc. Shift Alarm requires "
                    + "iOS 26 or later.")
                    .padding()
            }
        }
    }
}

Checkpoint: Build and run the final app on a physical device running iOS 26. You should see the Monsters, Inc. authorization screen with a purple alarm icon. Tap “Enable Alarms” to grant AlarmKit permissions. After authorization, you land on a two-tab interface: Shifts and Training. Load default shifts and default training presets. Toggle Sulley’s Morning Scare Shift alarm on — it is now scheduled as a system-level alarm that will ring at 7:00 AM even with Silent mode enabled. Start a training timer and minimize the app to see the Dynamic Island countdown. The alarm sounds when the timer reaches zero.

Where to Go From Here?

Congratulations! You’ve built Monsters, Inc. Shift Alarm — a complete alarm app that schedules system-level alarms for recurring scare floor shifts, runs countdown timers for training sessions, integrates the Dynamic Island for at-a-glance countdowns, and syncs alarm data to Apple Watch.

Here’s what you learned:

  • How to configure AlarmKit capabilities and request alarm authorization
  • Creating schedule-based alarms with AlarmConfiguration and AlarmRecurrence for recurring shifts
  • Creating countdown-based alarms for timed training sessions
  • Understanding Silent mode bypass behavior and interruption levels (.active, .timeSensitive, .critical)
  • Building a Dynamic Island Live Activity with ActivityKit to show real-time countdown progress
  • Syncing alarm data to Apple Watch via App Groups
  • Persisting alarm state with Codable models and UserDefaults

Ideas for extending this project:

  • Add custom alarm sounds for each monster — Sulley gets a roar, Mike gets a comedic horn, Randall gets a slithering hiss
  • Implement a snooze feature using AlarmKit’s snooze API to give monsters “five more minutes” before their shift
  • Build a shift calendar view that visualizes the weekly schedule using a custom SwiftUI calendar component
  • Add a Siri Shortcut with App Intents so monsters can say “Hey Siri, set Sulley’s morning shift” to create alarms by voice
  • Implement background task scheduling to check for upcoming shifts and send preparation reminders 30 minutes before each alarm