TheNoiseClock/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift

286 lines
10 KiB
Swift

//
// AlarmKitService.swift
// TheNoiseClock
//
// Created by Matt Bruce on 2/2/26.
//
import AlarmKit
import Bedrock
import Foundation
import SwiftUI
/// Service for managing alarms using AlarmKit (iOS 26+).
/// AlarmKit alarms cut through Focus modes and silent mode.
@MainActor
final class AlarmKitService {
// MARK: - Singleton
static let shared = AlarmKitService()
private let manager = AlarmManager.shared
private init() {
Design.debugLog("[alarmkit] AlarmKitService initialized")
Design.debugLog("[alarmkit] Authorization state: \(manager.authorizationState)")
}
// MARK: - Authorization
/// The current authorization state for AlarmKit
var authorizationState: AlarmManager.AuthorizationState {
manager.authorizationState
}
/// Request authorization to schedule alarms.
/// - Returns: `true` if authorized, `false` otherwise.
func requestAuthorization() async -> Bool {
Design.debugLog("[alarmkit] Requesting authorization, current state: \(manager.authorizationState)")
switch manager.authorizationState {
case .notDetermined:
do {
let state = try await manager.requestAuthorization()
Design.debugLog("[alarmkit] Authorization result: \(state)")
return state == .authorized
} catch {
Design.debugLog("[alarmkit] Authorization error: \(error)")
return false
}
case .authorized:
Design.debugLog("[alarmkit] Already authorized")
return true
case .denied:
Design.debugLog("[alarmkit] Authorization denied - user must enable in Settings")
return false
@unknown default:
Design.debugLog("[alarmkit] Unknown authorization state")
return false
}
}
// MARK: - Scheduling
/// Schedule an alarm using AlarmKit.
/// - Parameter alarm: The alarm to schedule.
func scheduleAlarm(_ alarm: Alarm) async throws {
Design.debugLog("[alarmkit] ========== SCHEDULING ALARM ==========")
Design.debugLog("[alarmkit] Label: \(alarm.label)")
Design.debugLog("[alarmkit] Time: \(alarm.time)")
Design.debugLog("[alarmkit] Sound: \(alarm.soundName)")
Design.debugLog("[alarmkit] Volume: \(alarm.volume)")
Design.debugLog("[alarmkit] ID: \(alarm.id)")
// Ensure we're authorized
if manager.authorizationState != .authorized {
Design.debugLog("[alarmkit] Not authorized, requesting...")
let authorized = await requestAuthorization()
guard authorized else {
Design.debugLog("[alarmkit] Authorization failed, cannot schedule alarm")
throw AlarmKitError.notAuthorized
}
}
// Create the sound for the alarm
let alarmSound = createAlarmSound(for: alarm)
Design.debugLog("[alarmkit] Created alarm sound: \(alarmSound)")
// Create the alert presentation with stop button and sound
let stopButton = AlarmButton(
text: "Stop",
textColor: .red,
systemImageName: "stop.fill"
)
let snoozeButton = AlarmButton(
text: "Snooze",
textColor: .blue,
systemImageName: "zzz"
)
let alert = AlarmPresentation.Alert(
title: LocalizedStringResource(stringLiteral: alarm.label),
sound: alarmSound,
stopButton: stopButton,
snoozeButton: snoozeButton
)
Design.debugLog("[alarmkit] Created alert with sound and buttons")
// Create metadata for the alarm
let metadata = NoiseClockAlarmMetadata(
alarmId: alarm.id.uuidString,
soundName: alarm.soundName,
snoozeDuration: alarm.snoozeDuration,
label: alarm.label,
volume: alarm.volume
)
Design.debugLog("[alarmkit] Created metadata: \(metadata)")
// Create alarm attributes
let attributes = AlarmAttributes<NoiseClockAlarmMetadata>(
presentation: AlarmPresentation(alert: alert),
metadata: metadata,
tintColor: Color.pink
)
Design.debugLog("[alarmkit] Created attributes with tint color")
// Create the schedule
let schedule = createSchedule(for: alarm)
Design.debugLog("[alarmkit] Created schedule: \(schedule)")
// Create countdown duration (5 min before alarm, 1 min after)
let countdownDuration = AlarmKit.Alarm.CountdownDuration(
preAlert: 300, // 5 minutes before
postAlert: 60 // 1 minute after
)
Design.debugLog("[alarmkit] Countdown duration: preAlert=300s, postAlert=60s")
// Create the alarm configuration
let configuration = AlarmManager.AlarmConfiguration<NoiseClockAlarmMetadata>(
countdownDuration: countdownDuration,
schedule: schedule,
attributes: attributes
)
Design.debugLog("[alarmkit] Created configuration")
// Schedule the alarm
do {
let scheduledAlarm = try await manager.schedule(
id: alarm.id,
configuration: configuration
)
Design.debugLog("[alarmkit] ✅ ALARM SCHEDULED SUCCESSFULLY")
Design.debugLog("[alarmkit] Scheduled ID: \(scheduledAlarm.id)")
Design.debugLog("[alarmkit] Scheduled state: \(scheduledAlarm.state)")
} catch {
Design.debugLog("[alarmkit] ❌ SCHEDULING FAILED: \(error)")
throw AlarmKitError.schedulingFailed(error)
}
}
// MARK: - Sound Configuration
/// Create an AlarmKit sound from the alarm's sound name.
private func createAlarmSound(for alarm: Alarm) -> AlarmKit.AlertSound {
let soundName = alarm.soundName
Design.debugLog("[alarmkit] Creating sound for: \(soundName)")
// Check if it's a bundled sound file (has extension)
if soundName.contains(".") {
// Extract filename without extension for named sound
let soundFileName = soundName
Design.debugLog("[alarmkit] Using named sound file: \(soundFileName)")
return .named(soundFileName)
} else {
// Assume it's a named sound resource
Design.debugLog("[alarmkit] Using named sound: \(soundName)")
return .named(soundName)
}
}
/// Cancel a scheduled alarm.
/// - Parameter id: The UUID of the alarm to cancel.
func cancelAlarm(id: UUID) {
Design.debugLog("[alarmkit] Cancelling alarm: \(id)")
do {
try manager.cancel(id: id)
Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)")
} catch {
Design.debugLog("[alarmkit] ❌ Cancel error: \(error)")
}
}
/// Stop an active alarm that is currently alerting.
/// - Parameter id: The UUID of the alarm to stop.
func stopAlarm(id: UUID) {
Design.debugLog("[alarmkit] Stopping alarm: \(id)")
do {
try manager.stop(id: id)
Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)")
} catch {
Design.debugLog("[alarmkit] ❌ Stop error: \(error)")
}
}
/// Snooze an active alarm by starting its countdown again.
/// - Parameter id: The UUID of the alarm to snooze.
func snoozeAlarm(id: UUID) {
Design.debugLog("[alarmkit] Snoozing alarm: \(id)")
do {
try manager.countdown(id: id)
Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)")
} catch {
Design.debugLog("[alarmkit] ❌ Snooze error: \(error)")
}
}
// MARK: - Alarm Updates
/// Async sequence that emits the current set of alarms whenever changes occur.
var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> {
manager.alarmUpdates
}
/// Log current state of all scheduled alarms
func logCurrentAlarms() {
Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========")
Task {
for await alarms in manager.alarmUpdates {
Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit")
for alarm in alarms {
Design.debugLog("[alarmkit] - ID: \(alarm.id)")
Design.debugLog("[alarmkit] State: \(alarm.state)")
}
break // Just log once
}
}
}
// MARK: - Private Methods
/// Create an AlarmKit schedule from an Alarm model.
private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule {
// Extract time components
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: alarm.time)
let hour = components.hour ?? 0
let minute = components.minute ?? 0
Design.debugLog("[alarmkit] Creating schedule for \(hour):\(String(format: "%02d", minute))")
let time = AlarmKit.Alarm.Schedule.Relative.Time(
hour: hour,
minute: minute
)
// For now, create a one-time alarm (non-repeating)
// Future: Support weekly repeating alarms based on alarm.repeatDays
let schedule = AlarmKit.Alarm.Schedule.relative(
AlarmKit.Alarm.Schedule.Relative(
time: time,
repeats: .never
)
)
Design.debugLog("[alarmkit] Schedule created: relative, repeats=never")
return schedule
}
}
// MARK: - Errors
enum AlarmKitError: Error, LocalizedError {
case notAuthorized
case schedulingFailed(Error)
var errorDescription: String? {
switch self {
case .notAuthorized:
return "AlarmKit is not authorized. Please enable alarm permissions in Settings."
case .schedulingFailed(let error):
return "Failed to schedule alarm: \(error.localizedDescription)"
}
}
}