292 lines
10 KiB
Swift
292 lines
10 KiB
Swift
//
|
|
// AlarmViewModel.swift
|
|
// TheNoiseClock
|
|
//
|
|
// Created by Matt Bruce on 9/7/25.
|
|
//
|
|
|
|
import Foundation
|
|
import Observation
|
|
import UserNotifications
|
|
import AudioPlaybackKit
|
|
|
|
/// ViewModel for alarm management
|
|
@Observable
|
|
class AlarmViewModel {
|
|
|
|
// MARK: - Properties
|
|
private let alarmService: AlarmService
|
|
private let notificationService: NotificationService
|
|
private let alarmSoundService = AlarmSoundService.shared
|
|
private let soundPlayer = SoundPlayer.shared
|
|
|
|
var activeAlarm: Alarm?
|
|
|
|
var alarms: [Alarm] {
|
|
alarmService.alarms
|
|
}
|
|
|
|
var systemSounds: [String] {
|
|
AppConstants.SystemSounds.availableSounds
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
init(alarmService: AlarmService = AlarmService(),
|
|
notificationService: NotificationService = NotificationService()) {
|
|
self.alarmService = alarmService
|
|
self.notificationService = notificationService
|
|
|
|
// Register alarm service with notification delegate for snooze handling
|
|
NotificationDelegate.shared.setAlarmService(alarmService)
|
|
}
|
|
|
|
// MARK: - Public Interface
|
|
func addAlarm(_ alarm: Alarm) async {
|
|
alarmService.addAlarm(alarm)
|
|
|
|
// Schedule notification if alarm is enabled
|
|
if alarm.isEnabled {
|
|
await notificationService.scheduleAlarmNotification(
|
|
id: alarm.id.uuidString,
|
|
title: alarm.label,
|
|
body: alarm.notificationMessage,
|
|
soundName: alarm.soundName,
|
|
date: alarm.time
|
|
)
|
|
requestKeepAwakePromptIfNeeded()
|
|
}
|
|
}
|
|
|
|
func updateAlarm(_ alarm: Alarm) async {
|
|
alarmService.updateAlarm(alarm)
|
|
|
|
// Reschedule notification
|
|
if alarm.isEnabled {
|
|
await notificationService.scheduleAlarmNotification(
|
|
id: alarm.id.uuidString,
|
|
title: alarm.label,
|
|
body: alarm.notificationMessage,
|
|
soundName: alarm.soundName,
|
|
date: alarm.time
|
|
)
|
|
requestKeepAwakePromptIfNeeded()
|
|
} else {
|
|
notificationService.cancelNotification(id: alarm.id.uuidString)
|
|
}
|
|
}
|
|
|
|
func deleteAlarm(id: UUID) async {
|
|
// Cancel notification first
|
|
notificationService.cancelNotification(id: id.uuidString)
|
|
|
|
// Then delete from storage
|
|
alarmService.deleteAlarm(id: id)
|
|
}
|
|
|
|
func toggleAlarm(id: UUID) async {
|
|
guard var alarm = alarmService.getAlarm(id: id) else { return }
|
|
|
|
alarm.isEnabled.toggle()
|
|
alarmService.updateAlarm(alarm)
|
|
|
|
// Schedule or cancel notification based on new state
|
|
if alarm.isEnabled {
|
|
await notificationService.scheduleAlarmNotification(
|
|
id: alarm.id.uuidString,
|
|
title: alarm.label,
|
|
body: alarm.notificationMessage,
|
|
soundName: alarm.soundName,
|
|
date: alarm.time
|
|
)
|
|
requestKeepAwakePromptIfNeeded()
|
|
} else {
|
|
notificationService.cancelNotification(id: id.uuidString)
|
|
}
|
|
}
|
|
|
|
func getAlarm(id: UUID) -> Alarm? {
|
|
return alarmService.getAlarm(id: id)
|
|
}
|
|
|
|
func createNewAlarm(
|
|
time: Date,
|
|
soundName: String = AppConstants.SystemSounds.defaultSound,
|
|
label: String = "Alarm",
|
|
notificationMessage: String = "Your alarm is ringing",
|
|
snoozeDuration: Int = 9,
|
|
isVibrationEnabled: Bool = true,
|
|
isLightFlashEnabled: Bool = false,
|
|
volume: Float = 1.0
|
|
) -> Alarm {
|
|
return Alarm(
|
|
id: UUID(),
|
|
time: time,
|
|
isEnabled: true,
|
|
soundName: soundName,
|
|
label: label,
|
|
notificationMessage: notificationMessage,
|
|
snoozeDuration: snoozeDuration,
|
|
isVibrationEnabled: isVibrationEnabled,
|
|
isLightFlashEnabled: isLightFlashEnabled,
|
|
volume: volume
|
|
)
|
|
}
|
|
|
|
func requestNotificationPermissions() async -> Bool {
|
|
return await notificationService.requestPermissions()
|
|
}
|
|
|
|
func requestKeepAwakePromptIfNeeded() {
|
|
guard !isKeepAwakeEnabled() else { return }
|
|
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
|
|
}
|
|
|
|
// MARK: - Active Alarm Handling
|
|
|
|
func handleAlarmNotification(userInfo: [AnyHashable: Any]?) {
|
|
guard let userInfo else { return }
|
|
if let alarm = resolveAlarm(from: userInfo) {
|
|
startActiveAlarm(alarm)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func stopActiveAlarm() {
|
|
soundPlayer.stopSound()
|
|
activeAlarm = nil
|
|
}
|
|
|
|
@MainActor
|
|
func snoozeActiveAlarm() {
|
|
guard let alarm = activeAlarm else { return }
|
|
scheduleSnoozeNotification(for: alarm)
|
|
stopActiveAlarm()
|
|
}
|
|
|
|
@MainActor
|
|
private func startActiveAlarm(_ alarm: Alarm) {
|
|
if activeAlarm?.id == alarm.id {
|
|
return
|
|
}
|
|
activeAlarm = alarm
|
|
playAlarmSound(for: alarm)
|
|
}
|
|
|
|
private func playAlarmSound(for alarm: Alarm) {
|
|
let resolvedSound = alarmSoundService.getAlarmSound(fileName: alarm.soundName)
|
|
?? alarmSoundService.getDefaultAlarmSound()
|
|
guard let sound = resolvedSound else { return }
|
|
soundPlayer.playSound(sound, volume: alarm.volume)
|
|
}
|
|
|
|
private func isKeepAwakeEnabled() -> Bool {
|
|
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
|
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
|
return ClockStyle().keepAwake
|
|
}
|
|
return style.keepAwake
|
|
}
|
|
|
|
private func scheduleSnoozeNotification(for alarm: Alarm) {
|
|
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
|
|
let snoozeAlarm = Alarm(
|
|
id: UUID(),
|
|
time: snoozeTime,
|
|
isEnabled: true,
|
|
soundName: alarm.soundName,
|
|
label: "\(alarm.label) (Snoozed)",
|
|
notificationMessage: "Snoozed: \(alarm.notificationMessage)",
|
|
snoozeDuration: alarm.snoozeDuration,
|
|
isVibrationEnabled: alarm.isVibrationEnabled,
|
|
isLightFlashEnabled: alarm.isLightFlashEnabled,
|
|
volume: alarm.volume
|
|
)
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = snoozeAlarm.label
|
|
content.body = snoozeAlarm.notificationMessage
|
|
if snoozeAlarm.soundName == "default" {
|
|
content.sound = .default
|
|
} else {
|
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
|
}
|
|
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
|
content.userInfo = [
|
|
AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
|
|
AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
|
|
AlarmNotificationKeys.isSnooze: true,
|
|
AlarmNotificationKeys.originalAlarmId: alarm.id.uuidString,
|
|
AlarmNotificationKeys.label: snoozeAlarm.label,
|
|
AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage,
|
|
AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration,
|
|
AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled,
|
|
AlarmNotificationKeys.volume: snoozeAlarm.volume
|
|
]
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(
|
|
timeInterval: snoozeAlarm.time.timeIntervalSinceNow,
|
|
repeats: false
|
|
)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: snoozeAlarm.id.uuidString,
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
Task {
|
|
do {
|
|
try await UNUserNotificationCenter.current().add(request)
|
|
} catch {
|
|
// Intentionally silent; notification system logs errors
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
|
|
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
|
let alarmId = UUID(uuidString: alarmIdString),
|
|
let alarm = alarmService.getAlarm(id: alarmId) {
|
|
return alarm
|
|
}
|
|
|
|
let title = (userInfo[AlarmNotificationKeys.label] as? String) ?? (userInfo[AlarmNotificationKeys.title] as? String)
|
|
let body = (userInfo[AlarmNotificationKeys.notificationMessage] as? String) ?? (userInfo[AlarmNotificationKeys.body] as? String)
|
|
let soundName = (userInfo[AlarmNotificationKeys.soundName] as? String) ?? AppConstants.SystemSounds.defaultSound
|
|
let snoozeDuration = intValue(from: userInfo[AlarmNotificationKeys.snoozeDuration]) ?? 9
|
|
let isVibrationEnabled = boolValue(from: userInfo[AlarmNotificationKeys.isVibrationEnabled]) ?? true
|
|
let volume = floatValue(from: userInfo[AlarmNotificationKeys.volume]) ?? 1.0
|
|
|
|
return Alarm(
|
|
time: Date(),
|
|
isEnabled: true,
|
|
soundName: soundName,
|
|
label: title ?? "Alarm",
|
|
notificationMessage: body ?? "Your alarm is ringing",
|
|
snoozeDuration: snoozeDuration,
|
|
isVibrationEnabled: isVibrationEnabled,
|
|
isLightFlashEnabled: false,
|
|
volume: volume
|
|
)
|
|
}
|
|
|
|
private func intValue(from value: Any?) -> Int? {
|
|
if let intValue = value as? Int { return intValue }
|
|
if let number = value as? NSNumber { return number.intValue }
|
|
return nil
|
|
}
|
|
|
|
private func floatValue(from value: Any?) -> Float? {
|
|
if let floatValue = value as? Float { return floatValue }
|
|
if let doubleValue = value as? Double { return Float(doubleValue) }
|
|
if let number = value as? NSNumber { return number.floatValue }
|
|
return nil
|
|
}
|
|
|
|
private func boolValue(from value: Any?) -> Bool? {
|
|
if let boolValue = value as? Bool { return boolValue }
|
|
if let number = value as? NSNumber { return number.boolValue }
|
|
return nil
|
|
}
|
|
}
|