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
- Xcode 26+ with iOS 26 deployment target
- A physical device running iOS 26 (AlarmKit requires real hardware — the Simulator does not support system-level alarm behavior)
- Familiarity with AlarmKit fundamentals
- Familiarity with Live Activities and Dynamic Island
- Familiarity with SwiftUI state management
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
- Step 1: Defining the Scare Floor Data Model
- Step 2: Configuring AlarmKit Capabilities
- Step 3: Building the Alarm Manager
- Step 4: Creating Schedule-Based Shift Alarms
- Step 5: Building the Shift Alarm List View
- Step 6: Creating Countdown-Based Training Alarms
- Step 7: Building the Training Timer View
- Step 8: Configuring Silent Mode Bypass
- Step 9: Adding Dynamic Island Integration
- Step 10: Adding Apple Watch Support
- Step 11: Assembling the Final App
- Where to Go From Here?
Getting Started
Let’s create the project and prepare the workspace for our Monsters, Inc. themed alarm app.
- Open Xcode and create a new project using the App template.
- Set the product name to MonstersShiftAlarm.
- Ensure the interface is SwiftUI and the language is Swift.
- Set the deployment target to iOS 26.0.
- 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
CodableandIdentifiableas 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.
- Select your project target in Xcode.
- Navigate to Signing & Capabilities.
- Click + Capability and add AlarmKit.
- In the capability configuration, ensure Alarm Scheduling is enabled.
Next, add the alarm usage description to your Info.plist:
| Key | Value |
|---|---|
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:
| Level | Behavior |
|---|---|
.active | Standard — bypasses Silent mode but respects Focus |
.timeSensitive | Bypasses Silent mode and most Focus filters |
.critical | Bypasses Silent mode, Focus filters, and Do Not Dist. |
Warning: The
.criticalinterruption level requires a special entitlement from Apple. For most alarm apps,.timeSensitiveis sufficient and does not require additional approval. Only use.criticalfor genuinely critical alarms like medication reminders or emergency alerts. Roz from the CDA would approve of this level of caution. SeeAlarmConfigurationin 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:
- In Xcode, go to File > New > Target.
- Choose Widget Extension.
- Name it ShiftAlarmWidgets.
- Uncheck “Include Configuration App Intent” (we will manage state manually).
- 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.
- In Xcode, go to File > New > Target.
- Choose Watch App for iOS App (under watchOS).
- Name it MonstersShiftAlarm Watch App.
- 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:
- Select each target in Xcode.
- Add the App Groups capability.
- 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
WCSessionfrom 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
AlarmConfigurationandAlarmRecurrencefor 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
ActivityKitto show real-time countdown progress - Syncing alarm data to Apple Watch via App Groups
- Persisting alarm state with
Codablemodels andUserDefaults
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