diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index 269b609..a390f41 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -102,18 +102,8 @@ struct ContentView: View { } .accentColor(AppAccent.primary) .background(Color.Branding.primary.ignoresSafeArea()) - .fullScreenCover(item: activeAlarmBinding) { alarm in - AlarmScreen( - alarm: alarm, - onSnooze: { - alarmViewModel.snoozeActiveAlarm() - }, - onStop: { - alarmViewModel.stopActiveAlarm() - } - ) - .interactiveDismissDisabled(true) - } + // Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island. + // No in-app alarm screen is needed - users interact with alarms via the system UI. // Onboarding overlay for first-time users if !onboardingState.hasCompletedWelcome { @@ -134,18 +124,12 @@ struct ContentView: View { } ) } - // Note: AlarmKit handles alarm alerts directly via the system. - // The in-app alarm screen is shown for alarms that are in the alerting state. - // AlarmKit's Live Activity provides the countdown and alerting UI on Lock Screen and Dynamic Island. .task { Design.debugLog("[ContentView] App launched - initializing AlarmKit") // Reschedule all enabled alarms with AlarmKit on app launch await alarmViewModel.rescheduleAllAlarms() - // Start observing AlarmKit alarm updates - alarmViewModel.startObservingAlarmUpdates() - Design.debugLog("[ContentView] AlarmKit initialization complete") } .onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in @@ -156,13 +140,6 @@ struct ContentView: View { .animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome) } - private var activeAlarmBinding: Binding { - Binding( - get: { alarmViewModel.activeAlarm }, - set: { alarmViewModel.activeAlarm = $0 } - ) - } - private func shouldShowKeepAwakePromptForTab() -> Bool { switch selectedTab { case .clock, .alarms: diff --git a/TheNoiseClock/App/TheNoiseClockApp.swift b/TheNoiseClock/App/TheNoiseClockApp.swift index 50260de..e69d2e6 100644 --- a/TheNoiseClock/App/TheNoiseClockApp.swift +++ b/TheNoiseClock/App/TheNoiseClockApp.swift @@ -4,6 +4,9 @@ // // Created by Matt Bruce on 9/7/25. // +// AlarmKit handles all alarm UI and sound playback. +// No notification delegate is needed with AlarmKit (iOS 26+). +// import SwiftUI import Bedrock @@ -12,12 +15,6 @@ import Bedrock @main struct TheNoiseClockApp: App { - // MARK: - Initialization - init() { - // Initialize notification delegate to handle snooze actions - _ = NotificationDelegate.shared - } - // MARK: - Body var body: some Scene { WindowGroup { diff --git a/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift b/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift index bb5e959..8311067 100644 --- a/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift +++ b/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift @@ -4,6 +4,9 @@ // // Created by Matt Bruce on 2/2/26. // +// App Intents for alarm actions from Live Activity and widget buttons. +// Note: These intents are duplicated in TheNoiseClockWidget target. +// import AlarmKit import AppIntents @@ -18,20 +21,20 @@ struct StopAlarmIntent: LiveActivityIntent { static var description = IntentDescription("Stops the currently ringing alarm") @Parameter(title: "Alarm ID") - var alarmID: String + var alarmId: String static var supportedModes: IntentModes { .background } init() { - self.alarmID = "" + self.alarmId = "" } - init(alarmID: String) { - self.alarmID = alarmID + init(alarmId: String) { + self.alarmId = alarmId } func perform() throws -> some IntentResult { - guard let uuid = UUID(uuidString: alarmID) else { + guard let uuid = UUID(uuidString: alarmId) else { throw AlarmIntentError.invalidAlarmID } @@ -49,20 +52,20 @@ struct SnoozeAlarmIntent: LiveActivityIntent { static var description = IntentDescription("Snoozes the currently ringing alarm") @Parameter(title: "Alarm ID") - var alarmID: String + var alarmId: String static var supportedModes: IntentModes { .background } init() { - self.alarmID = "" + self.alarmId = "" } - init(alarmID: String) { - self.alarmID = alarmID + init(alarmId: String) { + self.alarmId = alarmId } func perform() throws -> some IntentResult { - guard let uuid = UUID(uuidString: alarmID) else { + guard let uuid = UUID(uuidString: alarmId) else { throw AlarmIntentError.invalidAlarmID } @@ -74,28 +77,26 @@ struct SnoozeAlarmIntent: LiveActivityIntent { // MARK: - Open App Intent -/// Intent to open the app when the alarm fires. +/// Intent to open the app when the user taps the Live Activity. struct OpenAlarmAppIntent: LiveActivityIntent { static var title: LocalizedStringResource = "Open TheNoiseClock" static var description = IntentDescription("Opens the app to the alarm screen") + static var openAppWhenRun = true @Parameter(title: "Alarm ID") - var alarmID: String - - static var supportedModes: IntentModes { .foreground(.immediate) } + var alarmId: String init() { - self.alarmID = "" + self.alarmId = "" } - init(alarmID: String) { - self.alarmID = alarmID + init(alarmId: String) { + self.alarmId = alarmId } func perform() throws -> some IntentResult { - // The app will be opened due to .foreground(.immediate) - // The alarm screen will be shown based on the active alarm state + // The app will be opened due to openAppWhenRun = true return .result() } } diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift index 5857761..fdb7d3b 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift @@ -161,10 +161,54 @@ final class AlarmKitService { private func getSoundNameForAlarmKit(_ soundName: String) -> String { // AlarmKit expects the sound name without extension let nameWithoutExtension = (soundName as NSString).deletingPathExtension + let ext = (soundName as NSString).pathExtension + Design.debugLog("[alarmkit] Sound name for AlarmKit: \(nameWithoutExtension) (from: \(soundName))") + + // Verify the sound file exists in the bundle + if let _ = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) { + Design.debugLog("[alarmkit] ✅ Sound file found in main bundle: \(soundName)") + } else if let _ = Bundle.main.url(forResource: "AlarmSounds/\(nameWithoutExtension)", withExtension: ext) { + Design.debugLog("[alarmkit] ✅ Sound file found in AlarmSounds folder: \(soundName)") + } else { + Design.debugLog("[alarmkit] ⚠️ Sound file NOT found in bundle: \(soundName)") + Design.debugLog("[alarmkit] ⚠️ AlarmKit may not be able to play this sound") + + // Log bundle contents for debugging + logBundleSoundFiles() + } + return nameWithoutExtension } + /// Log available sound files in the bundle for debugging + private func logBundleSoundFiles() { + Design.debugLog("[alarmkit] ========== BUNDLE SOUND FILES ==========") + + // Check main bundle + if let resourcePath = Bundle.main.resourcePath { + let fileManager = FileManager.default + do { + let files = try fileManager.contentsOfDirectory(atPath: resourcePath) + let soundFiles = files.filter { $0.hasSuffix(".mp3") || $0.hasSuffix(".caf") || $0.hasSuffix(".wav") } + if soundFiles.isEmpty { + Design.debugLog("[alarmkit] No sound files in main bundle root") + } else { + Design.debugLog("[alarmkit] Sound files in main bundle: \(soundFiles)") + } + + // Check AlarmSounds subdirectory + let alarmSoundsPath = (resourcePath as NSString).appendingPathComponent("AlarmSounds") + if fileManager.fileExists(atPath: alarmSoundsPath) { + let alarmFiles = try fileManager.contentsOfDirectory(atPath: alarmSoundsPath) + Design.debugLog("[alarmkit] Sound files in AlarmSounds: \(alarmFiles)") + } + } catch { + Design.debugLog("[alarmkit] Error listing bundle: \(error)") + } + } + } + /// Cancel a scheduled alarm. /// - Parameter id: The UUID of the alarm to cancel. func cancelAlarm(id: UUID) { @@ -227,17 +271,30 @@ final class AlarmKitService { /// Create an AlarmKit schedule from an Alarm model. private func createSchedule(for alarm: Alarm) -> AlarmKit.Alarm.Schedule { + // Log the raw alarm time + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" + Design.debugLog("[alarmkit] Raw alarm.time: \(formatter.string(from: alarm.time))") + // Calculate the next trigger time let triggerDate = alarm.nextTriggerTime() - Design.debugLog("[alarmkit] Creating schedule for: \(triggerDate)") - Design.debugLog("[alarmkit] Current time: \(Date.now)") - Design.debugLog("[alarmkit] Time until alarm: \(triggerDate.timeIntervalSinceNow) seconds") + Design.debugLog("[alarmkit] Next trigger date: \(formatter.string(from: triggerDate))") + Design.debugLog("[alarmkit] Current time: \(formatter.string(from: Date.now))") + + let secondsUntil = triggerDate.timeIntervalSinceNow + let minutesUntil = secondsUntil / 60 + Design.debugLog("[alarmkit] Time until alarm: \(Int(secondsUntil)) seconds (\(String(format: "%.1f", minutesUntil)) minutes)") + + // Warn if the alarm is too far in the future (might indicate wrong date calculation) + if secondsUntil > 86400 { + Design.debugLog("[alarmkit] ⚠️ WARNING: Alarm is more than 24 hours away!") + } // Use fixed schedule for one-time alarms let schedule = AlarmKit.Alarm.Schedule.fixed(triggerDate) - Design.debugLog("[alarmkit] Schedule created: fixed at \(triggerDate)") + Design.debugLog("[alarmkit] Schedule created: fixed at \(formatter.string(from: triggerDate))") return schedule } } diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift index 9f11a17..3646ea1 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift @@ -99,13 +99,35 @@ class AlarmService { private func loadAlarms() { if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms), let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) { - alarms = decodedAlarms + // 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) func getEnabledAlarms() -> [Alarm] { return alarms.filter { $0.isEnabled } diff --git a/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift b/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift deleted file mode 100644 index f428eef..0000000 --- a/TheNoiseClock/Features/Alarms/Services/FocusModeService.swift +++ /dev/null @@ -1,256 +0,0 @@ -// -// FocusModeService.swift -// TheNoiseClock -// -// Created by Matt Bruce on 9/7/25. -// - -import Foundation -import Observation -import UIKit -import UserNotifications -import Bedrock - -/// Service to align notifications with Focus mode behavior -@Observable -class FocusModeService { - - // MARK: - Singleton - static let shared = FocusModeService() - - // MARK: - Properties - private(set) var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined - private(set) var timeSensitiveSetting: UNNotificationSetting = .notSupported - private(set) var scheduledDeliverySetting: UNNotificationSetting = .notSupported - private var notificationSettingsObserver: NSObjectProtocol? - - // MARK: - Initialization - private init() { - setupFocusModeMonitoring() - } - - deinit { - removeFocusModeObserver() - } - - // MARK: - Public Interface - - /// Check if Focus mode is currently active - var isAuthorized: Bool { - notificationAuthorizationStatus == .authorized - } - - /// Request notification permissions that work with Focus modes - func requestNotificationPermissions() async -> Bool { - do { - let granted = try await UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .sound, .badge, .provisional] - ) - - if granted { - // Configure notification settings for Focus mode compatibility - await configureNotificationSettings() - } - - await refreshNotificationSettings() - - return granted - } catch { - Design.debugLog("[general] Error requesting notification permissions: \(error)") - return false - } - } - - /// Configure notification settings to work with Focus modes - private func configureNotificationSettings() async { - // Create notification categories that work with Focus modes - let alarmCategory = UNNotificationCategory( - identifier: AlarmNotificationConstants.categoryIdentifier, - actions: [ - UNNotificationAction( - identifier: AlarmNotificationConstants.snoozeActionIdentifier, - title: "Snooze", - options: [] - ), - UNNotificationAction( - identifier: AlarmNotificationConstants.stopActionIdentifier, - title: "Stop", - options: [.destructive] - ) - ], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - // Register the category - UNUserNotificationCenter.current().setNotificationCategories([alarmCategory]) - - //Design.debugLog("[settings] Notification settings configured for Focus mode compatibility") - } - - /// Schedule alarm notification with Focus mode awareness - func scheduleAlarmNotification( - identifier: String, - title: String, - body: String, - date: Date, - soundName: String, - repeats: Bool = false, - respectFocusModes: Bool = true, - snoozeDuration: Int? = nil, - isVibrationEnabled: Bool? = nil, - volume: Float? = nil - ) { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - // Use the sound name directly since sounds.json now references CAF files - if soundName == "default" { - content.sound = UNNotificationSound.default - Design.debugLog("[settings] Using default notification sound") - } else if Bundle.main.url(forResource: soundName, withExtension: nil) != nil { - content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName)) - Design.debugLog("[settings] Using custom alarm sound: \(soundName)") - Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)") - } else { - content.sound = UNNotificationSound.default - Design.debugLog("[settings] Alarm sound not found in main bundle, falling back to default: \(soundName)") - } - content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier - - if !respectFocusModes, timeSensitiveSetting == .enabled { - content.interruptionLevel = .timeSensitive - } - var userInfo: [AnyHashable: Any] = [ - AlarmNotificationKeys.alarmId: identifier, - AlarmNotificationKeys.soundName: soundName, - AlarmNotificationKeys.repeats: repeats, - AlarmNotificationKeys.label: title, - AlarmNotificationKeys.notificationMessage: body - ] - if let snoozeDuration { - userInfo[AlarmNotificationKeys.snoozeDuration] = snoozeDuration - } - if let isVibrationEnabled { - userInfo[AlarmNotificationKeys.isVibrationEnabled] = isVibrationEnabled - } - if let volume { - userInfo[AlarmNotificationKeys.volume] = volume - } - content.userInfo = userInfo - - // Create trigger - let trigger: UNNotificationTrigger - if repeats { - let calendar = Calendar.current - let components = calendar.dateComponents([.hour, .minute], from: date) - trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) - } else { - // Use calendar trigger for one-time alarms to avoid time interval issues - let calendar = Calendar.current - let components = calendar.dateComponents([.hour, .minute], from: date) - trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) - } - - // Create request - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: trigger - ) - - // Schedule notification - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - Design.debugLog("[general] Error scheduling alarm notification: \(error)") - } else { - Design.debugLog("[settings] Alarm notification scheduled for \(date)") - } - } - } - - /// Cancel alarm notification - func cancelAlarmNotification(identifier: String) { - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) - Design.debugLog("[settings] Cancelled alarm notification: \(identifier)") - } - - /// Cancel all alarm notifications - func cancelAllAlarmNotifications() { - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - Design.debugLog("[settings] Cancelled all alarm notifications") - } - - // MARK: - Private Methods - - /// Set up monitoring for Focus mode changes - private func setupFocusModeMonitoring() { - notificationSettingsObserver = NotificationCenter.default.addObserver( - forName: UIApplication.willEnterForegroundNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { await self?.refreshNotificationSettings() } - } - - Task { await refreshNotificationSettings() } - } - - /// Remove Focus mode observer - private func removeFocusModeObserver() { - if let observer = notificationSettingsObserver { - NotificationCenter.default.removeObserver(observer) - notificationSettingsObserver = nil - } - } - - /// Refresh notification settings to align with Focus mode behavior. - @MainActor - func refreshNotificationSettings() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - notificationAuthorizationStatus = settings.authorizationStatus - timeSensitiveSetting = settings.timeSensitiveSetting - scheduledDeliverySetting = settings.scheduledDeliverySetting - - Design.debugLog("[settings] Notification settings updated: auth=\(settings.authorizationStatus), timeSensitive=\(settings.timeSensitiveSetting), scheduledDelivery=\(settings.scheduledDeliverySetting)") - } - - /// Get notification authorization status - func getNotificationAuthorizationStatus() async -> UNAuthorizationStatus { - let settings = await UNUserNotificationCenter.current().notificationSettings() - return settings.authorizationStatus - } - - /// Check if notifications are allowed in current Focus mode - func areNotificationsAllowed() async -> Bool { - let status = await getNotificationAuthorizationStatus() - return status == .authorized - } -} - -// MARK: - Focus Mode Configuration -extension FocusModeService { - - /// Configure app to work optimally with Focus modes - func configureForFocusModes() { - // Set up notification categories that work well with Focus modes - Task { - await configureNotificationSettings() - } - - Design.debugLog("[settings] App configured for Focus mode compatibility") - } - - /// Provide user guidance for Focus mode settings - func getFocusModeGuidance() -> String { - return """ - For the best experience with TheNoiseClock: - - 1. Allow notifications in your Focus mode settings - 2. Enable "Time Sensitive" notifications for alarms - 3. Consider adding TheNoiseClock to your Focus mode allowlist - - This ensures alarms will work even when Focus mode is active. - """ - } -} diff --git a/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift b/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift deleted file mode 100644 index 248201d..0000000 --- a/TheNoiseClock/Features/Alarms/Services/NotificationDelegate.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// NotificationDelegate.swift -// TheNoiseClock -// -// Created by Matt Bruce on 9/8/25. -// - -import UserNotifications -import Foundation -import Bedrock - -/// Delegate to handle notification actions (snooze, stop, etc.) -class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { - - // MARK: - Singleton - static let shared = NotificationDelegate() - - // MARK: - Properties - private var alarmService: AlarmService? - - // MARK: - Initialization - private override init() { - super.init() - setupNotificationCenter() - } - - // MARK: - Setup - private func setupNotificationCenter() { - UNUserNotificationCenter.current().delegate = self - Design.debugLog("[settings] Notification delegate configured") - } - - /// Set the alarm service instance (called from AlarmViewModel) - func setAlarmService(_ service: AlarmService) { - self.alarmService = service - } - - // MARK: - UNUserNotificationCenterDelegate - - /// Handle notification actions when app is in foreground - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - let actionIdentifier = response.actionIdentifier - let notification = response.notification - let userInfo = notification.request.content.userInfo - Design.debugLog("[alarms] didReceive notification. category=\(notification.request.content.categoryIdentifier) action=\(actionIdentifier)") - - Design.debugLog("[settings] Notification action received: \(actionIdentifier)") - - switch actionIdentifier { - case AlarmNotificationConstants.snoozeActionIdentifier: - handleSnoozeAction(userInfo: userInfo) - postAlarmAction(name: .alarmDidSnooze, notification: notification) - case AlarmNotificationConstants.stopActionIdentifier: - handleStopAction(userInfo: userInfo) - postAlarmAction(name: .alarmDidStop, notification: notification) - case UNNotificationDefaultActionIdentifier: - // User tapped the notification itself - handleNotificationTap(userInfo: userInfo) - postAlarmDidFire(notification: notification) - default: - Design.debugLog("[settings] Unknown action: \(actionIdentifier)") - } - - completionHandler() - } - - /// Handle notifications when app is in foreground - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - Design.debugLog("[alarms] willPresent notification. category=\(notification.request.content.categoryIdentifier)") - if notification.request.content.categoryIdentifier == AlarmNotificationConstants.categoryIdentifier { - postAlarmDidFire(notification: notification) - completionHandler([]) - return - } - - // Show non-alarm notifications even when app is in foreground - completionHandler([.banner, .sound, .badge]) - } - - // MARK: - Action Handlers - - private func handleSnoozeAction(userInfo: [AnyHashable: Any]) { - guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, - let alarmId = UUID(uuidString: alarmIdString), - let alarmService = self.alarmService, - let alarm = alarmService.getAlarm(id: alarmId) else { - Design.debugLog("[general] Could not find alarm for snooze action") - return - } - - Design.debugLog("[settings] Snoozing alarm: \(alarm.label) for \(alarm.snoozeDuration) minutes") - - // Calculate snooze time (current time + snooze duration) - let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60)) - Design.debugLog("[settings] Snooze time: \(snoozeTime)") - Design.debugLog("[settings] Current time: \(Date())") - - // Create a temporary alarm for the snooze - let snoozeAlarm = Alarm( - id: UUID(), // New ID for snooze alarm - 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 - ) - - // Schedule the snooze notification - Task { - await scheduleSnoozeNotification(snoozeAlarm, userInfo: userInfo) - } - } - - private func handleStopAction(userInfo: [AnyHashable: Any]) { - guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, - let alarmId = UUID(uuidString: alarmIdString) else { - Design.debugLog("[general] Could not find alarm ID for stop action") - return - } - - Design.debugLog("[settings] Stopping alarm: \(alarmId)") - - // Cancel any pending notifications for this alarm - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [alarmIdString]) - - // If this was a snooze alarm, we don't want to disable the original alarm - // Just cancel the current notification - } - - private func handleNotificationTap(userInfo: [AnyHashable: Any]) { - guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String, - let alarmId = UUID(uuidString: alarmIdString) else { - Design.debugLog("[general] Could not find alarm ID for notification tap") - return - } - - Design.debugLog("[settings] Notification tapped for alarm: \(alarmId)") - - // For now, just log the tap. In the future, this could open the alarm details - // or perform some other action when the user taps the notification - } - - // MARK: - Private Methods - - private func scheduleSnoozeNotification(_ snoozeAlarm: Alarm, userInfo: [AnyHashable: Any]) async { - 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: userInfo[AlarmNotificationKeys.alarmId] as? String ?? "", - AlarmNotificationKeys.label: snoozeAlarm.label, - AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage, - AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration, - AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled, - AlarmNotificationKeys.volume: snoozeAlarm.volume - ] - - // Create trigger for snooze time - let trigger = UNTimeIntervalNotificationTrigger( - timeInterval: snoozeAlarm.time.timeIntervalSinceNow, - repeats: false - ) - - // Create request - let request = UNNotificationRequest( - identifier: snoozeAlarm.id.uuidString, - content: content, - trigger: trigger - ) - - // Schedule notification - do { - try await UNUserNotificationCenter.current().add(request) - Design.debugLog("[settings] Snooze notification scheduled for \(snoozeAlarm.time)") - } catch { - Design.debugLog("[general] Error scheduling snooze notification: \(error)") - } - } - - // MARK: - Notification Center Bridge - - private func postAlarmDidFire(notification: UNNotification) { - var userInfo = notification.request.content.userInfo - userInfo[AlarmNotificationKeys.title] = notification.request.content.title - userInfo[AlarmNotificationKeys.body] = notification.request.content.body - NotificationCenter.default.post(name: .alarmDidFire, object: nil, userInfo: userInfo) - } - - private func postAlarmAction(name: Notification.Name, notification: UNNotification) { - var userInfo = notification.request.content.userInfo - userInfo[AlarmNotificationKeys.title] = notification.request.content.title - userInfo[AlarmNotificationKeys.body] = notification.request.content.body - NotificationCenter.default.post(name: name, object: nil, userInfo: userInfo) - } -} diff --git a/TheNoiseClock/Features/Alarms/Services/NotificationService.swift b/TheNoiseClock/Features/Alarms/Services/NotificationService.swift deleted file mode 100644 index a8094fd..0000000 --- a/TheNoiseClock/Features/Alarms/Services/NotificationService.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// NotificationService.swift -// TheNoiseClock -// -// Created by Matt Bruce on 9/7/25. -// - -import Foundation -import UserNotifications -import Observation -import Bedrock - -/// Service for managing system notifications -@Observable -class NotificationService { - - // MARK: - Properties - private(set) var isAuthorized = false - - // MARK: - Initialization - init() { - checkAuthorizationStatus() - } - - // MARK: - Public Interface - func requestPermissions() async -> Bool { - do { - let granted = try await UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .sound, .badge] - ) - isAuthorized = granted - return granted - } catch { - Design.debugLog("[general] Error requesting notification permissions: \(error)") - isAuthorized = false - return false - } - } - - func checkAuthorizationStatus() { - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - self.isAuthorized = settings.authorizationStatus == .authorized - } - } - } - - /// Schedule a single alarm notification - @discardableResult - func scheduleAlarmNotification( - id: String, - title: String, - body: String, - soundName: String, - date: Date - ) async -> Bool { - guard isAuthorized else { - Design.debugLog("[settings] Notifications not authorized") - return false - } - - let content = NotificationUtils.createAlarmContent( - title: title, - body: body, - soundName: soundName - ) - content.userInfo = [ - AlarmNotificationKeys.alarmId: id, - AlarmNotificationKeys.soundName: soundName, - AlarmNotificationKeys.label: title, - AlarmNotificationKeys.notificationMessage: body - ] - let trigger = NotificationUtils.createCalendarTrigger(for: date) - - return await NotificationUtils.scheduleNotification( - identifier: id, - content: content, - trigger: trigger - ) - } - - - /// Cancel a single notification - func cancelNotification(id: String) { - NotificationUtils.removeNotification(identifier: id) - } - - /// Cancel all notifications - func cancelAllNotifications() { - NotificationUtils.removeAllNotifications() - } - -} diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index 069cef0..4312794 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -6,24 +6,19 @@ // import AlarmKit -import AudioPlaybackKit import Bedrock import Foundation import Observation /// ViewModel for alarm management using AlarmKit (iOS 26+). /// AlarmKit provides alarms that cut through Focus modes and silent mode, -/// with built-in Live Activity countdown support. +/// with built-in Live Activity countdown and system alarm UI. @Observable class AlarmViewModel { // MARK: - Properties private let alarmService: AlarmService private let alarmKitService = AlarmKitService.shared - private let alarmSoundService = AlarmSoundService.shared - private let soundPlayer = SoundPlayer.shared - - var activeAlarm: Alarm? /// Whether AlarmKit is authorized var isAlarmKitAuthorized: Bool { @@ -50,7 +45,8 @@ class AlarmViewModel { return await alarmKitService.requestAuthorization() } - // MARK: - Public Interface + // MARK: - Alarm CRUD Operations + func addAlarm(_ alarm: Alarm) async { alarmService.addAlarm(alarm) @@ -135,113 +131,6 @@ class AlarmViewModel { volume: volume ) } - - // MARK: - Active Alarm Handling - - /// Stop an active alarm using AlarmKit. - /// - Parameter id: The UUID of the alarm to stop. - @MainActor - func stopAlarm(id: UUID) { - alarmKitService.stopAlarm(id: id) - - // Also stop any in-app sound if playing - soundPlayer.stopSound() - - // Disable the alarm after it fires (one-time alarms) - if let stored = alarmService.getAlarm(id: id) { - var updated = stored - updated.isEnabled = false - alarmService.updateAlarm(updated) - } - - activeAlarm = nil - Design.debugLog("[alarms] Alarm stopped: \(id)") - } - - /// Snooze an active alarm using AlarmKit's countdown feature. - /// - Parameter id: The UUID of the alarm to snooze. - @MainActor - func snoozeAlarm(id: UUID) { - alarmKitService.snoozeAlarm(id: id) - - // Stop any in-app sound if playing - soundPlayer.stopSound() - - activeAlarm = nil - Design.debugLog("[alarms] Alarm snoozed: \(id)") - } - - /// Legacy method for backward compatibility with notification-based alarms. - @MainActor - func stopActiveAlarm() { - guard let alarm = activeAlarm else { return } - stopAlarm(id: alarm.id) - } - - /// Legacy method for backward compatibility with notification-based alarms. - @MainActor - func snoozeActiveAlarm() { - guard let alarm = activeAlarm else { return } - snoozeAlarm(id: alarm.id) - } - - // MARK: - AlarmKit Updates - - /// Start observing AlarmKit alarm updates. - /// Call this when the app becomes active to sync with system alarm state. - func startObservingAlarmUpdates() { - Design.debugLog("[alarmkit] Starting to observe alarm updates") - Task { - for await alarms in alarmKitService.alarmUpdates { - await handleAlarmUpdates(alarms) - } - } - } - - @MainActor - private func handleAlarmUpdates(_ alarms: [AlarmKit.Alarm]) { - Design.debugLog("[alarmkit] Received alarm update: \(alarms.count) alarm(s)") - - for alarm in alarms { - Design.debugLog("[alarmkit] Alarm \(alarm.id): state=\(alarm.state)") - - switch alarm.state { - case .alerting: - // Alarm is currently ringing - find the matching app alarm - if let appAlarm = alarmService.getAlarm(id: alarm.id) { - activeAlarm = appAlarm - Design.debugLog("[alarmkit] 🔔 ALARM ALERTING: \(appAlarm.label)") - // Play alarm sound in-app as backup - playAlarmSound(appAlarm) - } else { - Design.debugLog("[alarmkit] ⚠️ Alerting alarm not found in storage: \(alarm.id)") - } - case .countdown: - Design.debugLog("[alarmkit] ⏱️ Alarm counting down: \(alarm.id)") - default: - Design.debugLog("[alarmkit] Other state for alarm \(alarm.id): \(alarm.state)") - } - } - } - - /// Play alarm sound in-app (backup for AlarmKit sound) - @MainActor - private func playAlarmSound(_ alarm: Alarm) { - Design.debugLog("[alarmkit] Playing in-app alarm sound: \(alarm.soundName)") - - // Get the Sound object from AlarmSoundService - if let sound = alarmSoundService.getAlarmSound(fileName: alarm.soundName) { - Design.debugLog("[alarmkit] Sound found: \(sound.name)") - soundPlayer.playSound(sound, volume: alarm.volume) - } else { - Design.debugLog("[alarmkit] ⚠️ Sound not found for: \(alarm.soundName)") - // Try to find any default alarm sound - if let defaultSound = alarmSoundService.getDefaultAlarmSound() { - Design.debugLog("[alarmkit] Using default sound: \(defaultSound.name)") - soundPlayer.playSound(defaultSound, volume: alarm.volume) - } - } - } // MARK: - App Lifecycle diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift b/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift deleted file mode 100644 index 7c309fd..0000000 --- a/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// AlarmScreen.swift -// TheNoiseClock -// -// Created by Matt Bruce on 2/2/26. -// - -import SwiftUI -import Bedrock - -/// Full-screen alarm UI for active alarms -struct AlarmScreen: View { - - let alarm: Alarm - let onSnooze: () -> Void - let onStop: () -> Void - - var body: some View { - ZStack { - Color.Branding.primary - .ignoresSafeArea() - - VStack(spacing: Design.Spacing.large) { - Spacer() - - Text(alarm.formattedTime()) - .font(.system(size: 72, weight: .bold, design: .rounded)) - .foregroundStyle(AppTextColors.primary) - - Text(alarm.label) - .font(.title2.weight(.semibold)) - .foregroundStyle(AppTextColors.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, Design.Spacing.large) - - Spacer() - - HStack(spacing: Design.Spacing.large) { - Button(action: onSnooze) { - Text("Snooze") - .frame(maxWidth: .infinity) - } - .buttonStyle(color: AppAccent.primary) - - Button(action: onStop) { - Text("Stop") - .frame(maxWidth: .infinity) - } - .buttonStyle(color: .red) - } - .padding(.horizontal, Design.Spacing.large) - .padding(.bottom, Design.Spacing.large) - } - } - } -} - -#Preview { - AlarmScreen( - alarm: Alarm(time: Date(), soundName: "digital-alarm.mp3", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0), - onSnooze: {}, - onStop: {} - ) -} diff --git a/TheNoiseClockWidget/AlarmIntents.swift b/TheNoiseClockWidget/AlarmIntents.swift new file mode 100644 index 0000000..a8a54f6 --- /dev/null +++ b/TheNoiseClockWidget/AlarmIntents.swift @@ -0,0 +1,119 @@ +// +// AlarmIntents.swift +// TheNoiseClockWidget +// +// Created by Matt Bruce on 2/2/26. +// +// App Intents for alarm actions from Live Activity and widget buttons. +// These intents are duplicated in the widget target for compilation. +// Note: Must be kept in sync with TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift +// + +import AlarmKit +import AppIntents +import Foundation + +// MARK: - Stop Alarm Intent + +/// Intent to stop an active alarm from the Live Activity or notification. +struct StopAlarmIntent: LiveActivityIntent { + + static var title: LocalizedStringResource = "Stop Alarm" + static var description = IntentDescription("Stops the currently ringing alarm") + + @Parameter(title: "Alarm ID") + var alarmId: String + + static var supportedModes: IntentModes { .background } + + init() { + self.alarmId = "" + } + + init(alarmId: String) { + self.alarmId = alarmId + } + + func perform() throws -> some IntentResult { + guard let uuid = UUID(uuidString: alarmId) else { + throw AlarmIntentError.invalidAlarmID + } + + try AlarmManager.shared.stop(id: uuid) + return .result() + } +} + +// MARK: - Snooze Alarm Intent + +/// Intent to snooze an active alarm from the Live Activity or notification. +struct SnoozeAlarmIntent: LiveActivityIntent { + + static var title: LocalizedStringResource = "Snooze Alarm" + static var description = IntentDescription("Snoozes the currently ringing alarm") + + @Parameter(title: "Alarm ID") + var alarmId: String + + static var supportedModes: IntentModes { .background } + + init() { + self.alarmId = "" + } + + init(alarmId: String) { + self.alarmId = alarmId + } + + func perform() throws -> some IntentResult { + guard let uuid = UUID(uuidString: alarmId) else { + throw AlarmIntentError.invalidAlarmID + } + + // Use countdown to postpone the alarm by its configured snooze duration + try AlarmManager.shared.countdown(id: uuid) + return .result() + } +} + +// MARK: - Open App Intent + +/// Intent to open the app when the user taps the Live Activity. +struct OpenAlarmAppIntent: LiveActivityIntent { + + static var title: LocalizedStringResource = "Open TheNoiseClock" + static var description = IntentDescription("Opens the app to the alarm screen") + static var openAppWhenRun = true + + @Parameter(title: "Alarm ID") + var alarmId: String + + init() { + self.alarmId = "" + } + + init(alarmId: String) { + self.alarmId = alarmId + } + + func perform() throws -> some IntentResult { + // The app will be opened due to openAppWhenRun = true + return .result() + } +} + +// MARK: - Errors + +enum AlarmIntentError: Error, LocalizedError { + case invalidAlarmID + case alarmNotFound + + var errorDescription: String? { + switch self { + case .invalidAlarmID: + return "Invalid alarm ID" + case .alarmNotFound: + return "Alarm not found" + } + } +} diff --git a/TheNoiseClockWidget/AlarmLiveActivityWidget.swift b/TheNoiseClockWidget/AlarmLiveActivityWidget.swift index fbb9d69..cbe8836 100644 --- a/TheNoiseClockWidget/AlarmLiveActivityWidget.swift +++ b/TheNoiseClockWidget/AlarmLiveActivityWidget.swift @@ -6,6 +6,7 @@ // import AlarmKit +import AppIntents import SwiftUI import WidgetKit @@ -21,7 +22,7 @@ struct AlarmLiveActivityWidget: Widget { ) } dynamicIsland: { context in DynamicIsland { - // Expanded regions + // Expanded regions - shown when long-pressed or alerting DynamicIslandExpandedRegion(.leading) { if let metadata = context.attributes.metadata { AlarmTitleView(metadata: metadata) @@ -40,13 +41,14 @@ struct AlarmLiveActivityWidget: Widget { ) } } compactLeading: { - // Compact leading - countdown text - CountdownTextView(state: context.state) + // Compact leading - alarm icon during countdown + Image(systemName: "alarm.fill") .foregroundStyle(context.attributes.tintColor) } compactTrailing: { - // Compact trailing - progress ring - AlarmProgressView(state: context.state) - .frame(maxWidth: 32) + // Compact trailing - countdown text + CountdownTextView(state: context.state) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) } minimal: { // Minimal - just an alarm icon Image(systemName: "alarm.fill") @@ -66,6 +68,10 @@ struct LockScreenAlarmView: View { attributes.metadata?.label ?? "Alarm" } + private var alarmId: String { + attributes.metadata?.alarmId ?? "" + } + var body: some View { VStack(spacing: 12) { // Alarm label @@ -73,8 +79,9 @@ struct LockScreenAlarmView: View { .font(.headline) .foregroundStyle(.primary) - // Countdown state + // Content based on state if case .countdown(let countdown) = state.mode { + // Countdown state - show timer VStack(spacing: 4) { Text("Alarm in") .font(.caption) @@ -89,14 +96,34 @@ struct LockScreenAlarmView: View { .font(.title3) .foregroundStyle(.secondary) } else { - // Other states (alerting, etc.) - VStack(spacing: 4) { + // Alerting state - show ringing UI with action buttons + VStack(spacing: 16) { Image(systemName: "alarm.waves.left.and.right.fill") - .font(.system(size: 32)) + .font(.system(size: 40)) .foregroundStyle(attributes.tintColor) - .symbolEffect(.pulse) + .symbolEffect(.bounce.byLayer, options: .repeating) + Text("Alarm Ringing") .font(.title3.weight(.semibold)) + + // Action buttons + HStack(spacing: 20) { + // Snooze button + Button(intent: SnoozeAlarmIntent(alarmId: alarmId)) { + Label("Snooze", systemImage: "zzz") + .font(.callout.weight(.medium)) + } + .buttonStyle(.bordered) + .tint(.blue) + + // Stop button + Button(intent: StopAlarmIntent(alarmId: alarmId)) { + Label("Stop", systemImage: "stop.fill") + .font(.callout.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .tint(.red) + } } } } @@ -111,15 +138,47 @@ struct ExpandedAlarmView: View { let attributes: AlarmAttributes let state: AlarmPresentationState + private var alarmId: String { + attributes.metadata?.alarmId ?? "" + } + + private var isAlerting: Bool { + if case .countdown = state.mode { return false } + if case .paused = state.mode { return false } + return true + } + var body: some View { - HStack { - CountdownTextView(state: state) - .font(.headline) - - Spacer() - - AlarmProgressView(state: state) - .frame(maxHeight: 30) + if isAlerting { + // Alerting state - show action buttons + HStack(spacing: 16) { + Button(intent: SnoozeAlarmIntent(alarmId: alarmId)) { + Text("Snooze") + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.blue) + + Button(intent: StopAlarmIntent(alarmId: alarmId)) { + Text("Stop") + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + } + } else { + // Countdown state - show countdown info + HStack { + CountdownTextView(state: state) + .font(.headline) + + Spacer() + + AlarmProgressView(state: state) + .frame(maxHeight: 30) + } } } }