TheNoiseClock/TheNoiseClock/Features/Alarms/Services/AlarmService.swift

147 lines
5.1 KiB
Swift

//
// AlarmService.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
// NOTE: This service now only handles alarm persistence.
// Alarm scheduling is handled by AlarmKitService (iOS 26+).
// The old notification scheduling code has been removed.
//
import Foundation
import Observation
import Bedrock
/// Service for managing alarm persistence.
/// Alarm scheduling is handled by AlarmKitService.
@Observable
class AlarmService {
// MARK: - Singleton
static let shared = AlarmService()
// MARK: - Properties
private(set) var alarms: [Alarm] = []
private var alarmLookup: [UUID: Int] = [:]
private var persistenceWorkItem: DispatchWorkItem?
// MARK: - Initialization
init() {
loadAlarms()
Design.debugLog("[alarms] AlarmService initialized with \(alarms.count) alarms")
}
// MARK: - Public Interface
/// Add an alarm to storage. Does NOT schedule - caller should use AlarmKitService.
func addAlarm(_ alarm: Alarm) {
Design.debugLog("[alarms] AlarmService.addAlarm: \(alarm.label) at \(alarm.time)")
alarms.append(alarm)
updateAlarmLookup()
saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
}
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
func updateAlarm(_ alarm: Alarm) {
guard let index = alarmLookup[alarm.id] else {
Design.debugLog("[alarms] AlarmService.updateAlarm: alarm not found \(alarm.id)")
return
}
Design.debugLog("[alarms] AlarmService.updateAlarm: \(alarm.label) enabled=\(alarm.isEnabled)")
alarms[index] = alarm
updateAlarmLookup()
saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
}
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
func deleteAlarm(id: UUID) {
Design.debugLog("[alarms] AlarmService.deleteAlarm: \(id)")
alarms.removeAll { $0.id == id }
updateAlarmLookup()
saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
}
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
func toggleAlarm(id: UUID) {
guard let index = alarmLookup[id] else { return }
alarms[index].isEnabled.toggle()
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
}
func getAlarm(id: UUID) -> Alarm? {
return alarms.first { $0.id == id }
}
// MARK: - Private Methods
private func updateAlarmLookup() {
alarmLookup.removeAll()
for (index, alarm) in alarms.enumerated() {
alarmLookup[alarm.id] = index
}
}
private func saveAlarms() {
persistenceWorkItem?.cancel()
let alarmsSnapshot = self.alarms
let work = DispatchWorkItem {
if let encoded = try? JSONEncoder().encode(alarmsSnapshot) {
UserDefaults.standard.set(encoded, forKey: AppConstants.StorageKeys.savedAlarms)
}
}
persistenceWorkItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + AppConstants.PersistenceDelays.alarms,
execute: work
)
}
private func loadAlarms() {
if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms),
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
// Migrate sound file extensions from .caf to .mp3
alarms = decodedAlarms.map { alarm in
var migratedAlarm = alarm
migratedAlarm.soundName = migrateSoundName(alarm.soundName)
return migratedAlarm
}
updateAlarmLookup()
Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage")
// Save migrated alarms if any changes were made
let needsMigration = zip(decodedAlarms, alarms).contains { $0.soundName != $1.soundName }
if needsMigration {
Design.debugLog("[alarms] Sound file migration applied, saving...")
saveAlarms()
}
// Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms()
}
}
/// Migrate sound file names from .caf to .mp3
private func migrateSoundName(_ soundName: String) -> String {
if soundName.hasSuffix(".caf") {
let migrated = soundName.replacingOccurrences(of: ".caf", with: ".mp3")
Design.debugLog("[alarms] Migrating sound: \(soundName) -> \(migrated)")
return migrated
}
return soundName
}
/// Get all enabled alarms (for rescheduling with AlarmKit)
var enabledAlarms: [Alarm] {
return alarms.filter { $0.isEnabled }
}
func getEnabledAlarms() -> [Alarm] {
return enabledAlarms
}
}