# AlarmKit patterns

Longer, end-to-end examples. Everything here targets iOS 26 and iPadOS 26 with
Swift 6.3. Code that uses the stopButton-free
`AlarmPresentation.Alert(title:secondaryButton:secondaryButtonBehavior:)`
initializer is marked `@available(iOS 26.1, *)`.

## Contents

- [Full alarm flow with intents](#full-alarm-flow-with-intents)
- [Full timer flow](#full-timer-flow)
- [Authorization manager](#authorization-manager)
- [State store with async sequences](#state-store-with-async-sequences)
- [Widget extension for countdown and paused UI](#widget-extension-for-countdown-and-paused-ui)
- [Recurrence](#recurrence)
- [Snooze and custom secondary actions](#snooze-and-custom-secondary-actions)
- [Info.plist](#infoplist)
- [Error handling](#error-handling)
- [Apple documentation](#apple-documentation)

## Full alarm flow with intents

Stop and snooze intents are `LiveActivityIntent` types from AppIntents. Each one
receives the alarm ID as a string parameter and calls back into `AlarmManager`.

```swift
import AlarmKit
import AppIntents
import SwiftUI

struct DismissMedicineAlarm: LiveActivityIntent {
    static let title: LocalizedStringResource = "Dismiss medicine reminder"

    @Parameter(title: "Alarm ID")
    var alarmID: String

    init() {}
    init(alarmID: UUID) { self.alarmID = alarmID.uuidString }

    func perform() async throws -> some IntentResult {
        if let id = UUID(uuidString: alarmID) {
            try AlarmManager.shared.stop(id: id)
        }
        return .result()
    }
}

struct RemindAgainIntent: LiveActivityIntent {
    static let title: LocalizedStringResource = "Remind again"

    @Parameter(title: "Alarm ID")
    var alarmID: String

    init() {}
    init(alarmID: UUID) { self.alarmID = alarmID.uuidString }

    func perform() async throws -> some IntentResult {
        if let id = UUID(uuidString: alarmID) {
            try AlarmManager.shared.countdown(id: id)
        }
        return .result()
    }
}

struct DoseInfo: AlarmMetadata {
    var medicine: String
    var doseLabel: String
}

enum DoseAlarmError: Error {
    case accessNotGranted
}

@available(iOS 26.1, *)
@MainActor
func scheduleDoseReminder(hour: Int, minute: Int, medicine: String) async throws -> Alarm {
    let center = AlarmManager.shared
    guard try await center.requestAuthorization() == .authorized else {
        throw DoseAlarmError.accessNotGranted
    }

    let alert = AlarmPresentation.Alert(
        title: LocalizedStringResource(stringLiteral: "Time for \(medicine)"),
        secondaryButton: AlarmButton(text: "In 5 min", textColor: .white, systemImageName: "clock.arrow.circlepath"),
        secondaryButtonBehavior: .countdown
    )
    let attributes = AlarmAttributes(
        presentation: AlarmPresentation(alert: alert),
        metadata: DoseInfo(medicine: medicine, doseLabel: "1 tablet"),
        tintColor: .indigo
    )

    let doseTime = Alarm.Schedule.Relative.Time(hour: hour, minute: minute)
    let id = UUID()
    let config = AlarmManager.AlarmConfiguration(
        countdownDuration: Alarm.CountdownDuration(preAlert: nil, postAlert: 5 * 60),
        schedule: .relative(.init(time: doseTime)),
        attributes: attributes,
        stopIntent: DismissMedicineAlarm(alarmID: id),
        secondaryIntent: RemindAgainIntent(alarmID: id),
        sound: .default
    )
    return try await center.schedule(id: id, configuration: config)
}
```

Generate the `UUID` first. Both intents need to carry the same ID string that
`schedule(id:configuration:)` will use, so the ID must exist before the intents
are built. Remember the intents themselves are optional; this example passes
them only to show the wiring. Declare the intent's `title` with `static let`:
in the Swift 6 language mode a `static var` is rejected as unprotected global
mutable state.

## Full timer flow

```swift
import AlarmKit
import SwiftUI

struct BakeStep: AlarmMetadata {
    var loafName: String
    var stage: String
}

@available(iOS 26.1, *)
@MainActor
func startProofingTimer(minutes: Double) async throws -> Alarm {
    let presentation = AlarmPresentation(
        alert: AlarmPresentation.Alert(title: "Dough has proofed", secondaryButton: nil, secondaryButtonBehavior: nil),
        countdown: AlarmPresentation.Countdown(
            title: "Proofing",
            pauseButton: AlarmButton(text: "Pause", textColor: .brown, systemImageName: "pause.fill")
        ),
        paused: AlarmPresentation.Paused(
            title: "Proofing paused",
            resumeButton: AlarmButton(text: "Resume", textColor: .brown, systemImageName: "play.fill")
        )
    )
    let attributes = AlarmAttributes(
        presentation: presentation,
        metadata: BakeStep(loafName: "Rye", stage: "Second rise"),
        tintColor: .orange
    )
    let config = AlarmManager.AlarmConfiguration.timer(
        duration: minutes * 60,
        attributes: attributes,
        stopIntent: nil,
        sound: .default
    )
    let center = AlarmManager.shared
    return try await center.schedule(id: UUID(), configuration: config)
}
```

## Authorization manager

```swift
import AlarmKit
import Observation
import SwiftUI

@Observable
@MainActor
final class AlarmAccess {
    private let center = AlarmManager.shared
    private(set) var state: AlarmManager.AuthorizationState

    init() {
        state = AlarmManager.shared.authorizationState
    }

    func askIfNeeded() async {
        guard state == .notDetermined else { return }
        if let answer = try? await AlarmManager.shared.requestAuthorization() {
            state = answer
        }
    }

    func follow() async {
        for await next in center.authorizationUpdates {
            state = next
        }
    }
}

struct AlarmAccessView: View {
    @State private var access = AlarmAccess()

    var body: some View {
        Group {
            switch access.state {
            case .authorized:
                Text("Alarms are on")
            case .denied:
                ContentUnavailableView(
                    "Alarms are off",
                    systemImage: "alarm",
                    description: Text("Turn them on in Settings > this app > Alarms & Timers.")
                )
            default:
                Button("Allow alarms") {
                    Task { await access.askIfNeeded() }
                }
            }
        }
        .task { await access.follow() }
    }
}
```

The path users follow to recover from a denial is Settings, then the app's own
page, then Alarms & Timers.

`askIfNeeded()` reads `AlarmManager.shared` at the call instead of the stored
`center`. `AlarmManager` is a non-`Sendable` class and `requestAuthorization()`
is a nonisolated `async` method, so calling it on a property of a main-actor
object sends main-actor state off the actor, and Swift 6 rejects that in its
region-isolation pass ("sending 'self.center' risks causing data races").

## State store with async sequences

```swift
import AlarmKit
import Observation
import SwiftUI

@Observable
@MainActor
final class AlarmBoard {
    private let center = AlarmManager.shared
    private(set) var items: [Alarm] = []

    func reload() throws {
        items = try center.alarms
    }

    func observe() async {
        for await snapshot in center.alarmUpdates {
            items = snapshot
        }
    }

    func item(for id: Alarm.ID) -> Alarm? {
        items.first { $0.id == id }
    }

    func items(in state: Alarm.State) -> [Alarm] {
        items.filter { $0.state == state }
    }

    func cancel(_ alarmID: Alarm.ID) throws { try center.cancel(id: alarmID) }
    func pause(_ alarmID: Alarm.ID) throws { try center.pause(id: alarmID) }
    func resume(_ alarmID: Alarm.ID) throws { try center.resume(id: alarmID) }
    func stop(_ alarmID: Alarm.ID) throws { try center.stop(id: alarmID) }
    func snooze(_ alarmID: Alarm.ID) throws { try center.countdown(id: alarmID) }
}

struct AlarmBoardView: View {
    @State private var board = AlarmBoard()

    var body: some View {
        List(board.items) { alarm in
            HStack {
                Text(alarm.id.uuidString.prefix(6))
                Spacer()
                switch alarm.state {
                case .scheduled:
                    Button("Cancel") { try? board.cancel(alarm.id) }
                case .countdown:
                    Button("Pause") { try? board.pause(alarm.id) }
                case .paused:
                    Button("Resume") { try? board.resume(alarm.id) }
                case .alerting:
                    Button("Stop") { try? board.stop(alarm.id) }
                @unknown default:
                    EmptyView()
                }
            }
        }
        .task {
            try? board.reload()
            await board.observe()
        }
    }
}
```

## Widget extension for countdown and paused UI

The system only falls back to its own countdown UI in narrow situations, such as
a device that restarted and has not been unlocked yet. Anything that shows a
countdown should ship this extension.

```swift
import ActivityKit
import AlarmKit
import SwiftUI
import WidgetKit

struct LapInfo: AlarmMetadata {
    var routeName: String
    var segment: String
}

struct LapAlarmActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: AlarmAttributes<LapInfo>.self) { context in
            LapLockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "figure.run")
                        .foregroundStyle(context.attributes.tintColor)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    remainingLabel(for: context.state.mode)
                        .monospacedDigit()
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.attributes.metadata?.routeName ?? "Run")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text(context.attributes.metadata?.segment ?? "")
                        .font(.caption)
                }
            } compactLeading: {
                Image(systemName: "figure.run")
            } compactTrailing: {
                remainingLabel(for: context.state.mode)
                    .monospacedDigit()
                    .frame(maxWidth: 44)
            } minimal: {
                Image(systemName: "figure.run")
            }
            .keylineTint(context.attributes.tintColor)
        }
    }
}

struct LapLockScreenView: View {
    let context: ActivityViewContext<AlarmAttributes<LapInfo>>

    var body: some View {
        switch context.state.mode {
        case .countdown(let run):
            VStack(alignment: .leading) {
                Text(context.attributes.presentation.countdown?.title ?? "Running")
                ProgressView(value: run.previouslyElapsedDuration, total: run.totalCountdownDuration)
            }
            .padding()
        case .paused:
            Label("Paused", systemImage: "pause.circle").padding()
        case .alert:
            EmptyView()
        @unknown default:
            EmptyView()
        }
    }
}

@ViewBuilder
func remainingLabel(for mode: AlarmPresentationState.Mode) -> some View {
    switch mode {
    case .countdown(let run):
        Text(run.fireDate, style: .timer)
    case .paused(let hold):
        let left = hold.totalCountdownDuration - hold.previouslyElapsedDuration
        Text(Duration.seconds(left).formatted(.time(pattern: .minuteSecond)))
    case .alert(let ring):
        Text(String(format: "%02d:%02d", ring.time.hour, ring.time.minute))
    @unknown default:
        EmptyView()
    }
}
```

The alert case renders `EmptyView()` on the Lock Screen because the system draws
the ringing UI itself.

Fields by mode:

| Mode | Fields |
|---|---|
| `.countdown` | `fireDate`, `previouslyElapsedDuration`, `totalCountdownDuration` |
| `.paused` | `previouslyElapsedDuration`, `totalCountdownDuration` |
| `.alert` | `time` (`hour`, `minute`) |

## Recurrence

```swift
import AlarmKit
import Foundation

let allWeek: [Locale.Weekday] = [.monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday]
let officeDays: [Locale.Weekday] = [.monday, .tuesday, .wednesday, .thursday, .friday]
let restDays: [Locale.Weekday] = [.saturday, .sunday]

let bedtime = Alarm.Schedule.relative(.init(time: .init(hour: 21, minute: 30), repeats: .weekly(allWeek)))
let commute = Alarm.Schedule.relative(.init(time: .init(hour: 7, minute: 15), repeats: .weekly(officeDays)))
let lazyMorning = Alarm.Schedule.relative(.init(time: .init(hour: 9, minute: 45), repeats: .weekly(restDays)))

func examMorning() -> Alarm.Schedule? {
    let parts = DateComponents(calendar: .current, year: 2027, month: 1, day: 8, hour: 6, minute: 50)
    guard let when = Calendar.current.date(from: parts) else { return nil }
    return .fixed(when)
}
```

"Every day" is simply `.weekly` with all seven weekdays. A single calendar date
is a `Date` built from `DateComponents` and passed to `.fixed`.

## Snooze and custom secondary actions

For a standard snooze, give the alert a `.countdown` secondary button and set
`postAlert`; that value is the snooze length. A `Countdown` presentation is
what the user sees while the snooze runs. Metadata can be `nil`; give the
compiler the type (`Optional<NapInfo>.none`, or `nil as NapInfo?`) when it
cannot infer the generic parameter.

```swift
import AlarmKit
import AppIntents
import SwiftUI

struct NapInfo: AlarmMetadata {}

@available(iOS 26.1, *)
func napWithSnooze() -> AlarmManager.AlarmConfiguration<NapInfo> {
    let presentation = AlarmPresentation(
        alert: .init(title: "Nap over",
                     secondaryButton: AlarmButton(text: "5 more", textColor: .white, systemImageName: "moon.zzz"),
                     secondaryButtonBehavior: .countdown),
        countdown: .init(title: "Snoozing", pauseButton: nil)
    )
    let attributes = AlarmAttributes(presentation: presentation, metadata: Optional<NapInfo>.none, tintColor: .purple)
    return AlarmManager.AlarmConfiguration(
        countdownDuration: .init(preAlert: nil, postAlert: 300),
        schedule: .fixed(Date(timeIntervalSinceNow: 1_200)),
        attributes: attributes,
        sound: .default
    )
}

struct ShowPackingList: LiveActivityIntent {
    static let title: LocalizedStringResource = "Show packing list"
    static let openAppWhenRun = true

    func perform() async -> some IntentResult {
        .result()
    }
}

@available(iOS 26.1, *)
func departureAlarm() -> AlarmManager.AlarmConfiguration<NapInfo> {
    let alert = AlarmPresentation.Alert(
        title: "Leave for the airport",
        secondaryButton: AlarmButton(text: "Checklist", textColor: .white, systemImageName: "list.bullet"),
        secondaryButtonBehavior: .custom
    )
    let attributes = AlarmAttributes<NapInfo>(presentation: .init(alert: alert), tintColor: .blue)
    return .alarm(
        schedule: .fixed(Date(timeIntervalSinceNow: 7_200)),
        attributes: attributes,
        stopIntent: nil,
        secondaryIntent: ShowPackingList(),
        sound: .default
    )
}
```

With `.custom`, pressing the secondary button runs `secondaryIntent` instead of
starting a countdown.

## Info.plist

| Key | Type | Notes |
|---|---|---|
| `NSAlarmKitUsageDescription` | String | Required. Without a non-empty value every `schedule(id:configuration:)` call fails, so the app cannot create alarms at all. |

- Put it in the containing app's Info.plist, not the extension's.
- Apple does not list `NSSupportsLiveActivities` as part of AlarmKit setup.
- Using countdown presentation means adding a widget extension target.

## Error handling

`AlarmManager.AlarmConfiguration` is generic over the metadata type, written in
full as `AlarmManager.AlarmConfiguration<Metadata>`.

```swift
import AlarmKit

enum ScheduleOutcome {
    case scheduled(Alarm)
    case needsAccess
    case tooMany
    case failed(Error)
}

func trySchedule<M: AlarmMetadata>(_ config: AlarmManager.AlarmConfiguration<M>) async -> ScheduleOutcome {
    let center = AlarmManager.shared
    guard center.authorizationState == .authorized else { return .needsAccess }
    do {
        return .scheduled(try await center.schedule(id: UUID(), configuration: config))
    } catch let limitError as AlarmManager.AlarmError {
        switch limitError {
        case .maximumLimitReached:
            return .tooMany
        @unknown default:
            return .failed(limitError)
        }
    } catch {
        return .failed(error)
    }
}

func holdCountdown(_ alarmID: Alarm.ID) {
    do {
        try AlarmManager.shared.pause(id: alarmID)
    } catch {
        // pause(id:) only succeeds while the alarm is in .countdown
    }
}
```

When `.maximumLimitReached` comes back, ask the user to remove an existing
alarm before adding another. The transition calls (`cancel`, `pause`,
`resume`, `stop`, `countdown`) throw when the alarm is in the wrong state, so
wrap them in `do/catch`; for example `pause` is valid only in `.countdown`.

## Apple documentation

- [AlarmKit](https://developer.apple.com/documentation/alarmkit)
- [AlarmManager](https://developer.apple.com/documentation/alarmkit/alarmmanager)
- [AlarmAttributes](https://developer.apple.com/documentation/alarmkit/alarmattributes)
- [AlarmPresentation](https://developer.apple.com/documentation/alarmkit/alarmpresentation)
- [AlarmPresentationState](https://developer.apple.com/documentation/alarmkit/alarmpresentationstate)
- [AlarmButton](https://developer.apple.com/documentation/alarmkit/alarmbutton)
- [Alarm](https://developer.apple.com/documentation/alarmkit/alarm)
- [Alarm.Schedule](https://developer.apple.com/documentation/alarmkit/alarm/schedule)
- [Alarm.CountdownDuration](https://developer.apple.com/documentation/alarmkit/alarm/countdownduration)
- [Apple sample: scheduling alarms](https://developer.apple.com/documentation/alarmkit/scheduling-an-alarm-with-alarmkit)
