286 lines
10 KiB
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)"
|
|
}
|
|
}
|
|
}
|