diff --git a/PRD.md b/PRD.md index ff19360..d53b9a2 100644 --- a/PRD.md +++ b/PRD.md @@ -89,38 +89,32 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Responsive layout**: Optimized for both portrait and landscape orientations - **AudioPlaybackKit integration**: Powered by reusable Swift package for audio functionality -### 6. Advanced Alarm System +### 6. Advanced Alarm System (Powered by AlarmKit) +- **AlarmKit integration**: iOS 26+ AlarmKit framework for reliable alarms that cut through Focus modes and silent mode - **Multiple alarms**: Create and manage unlimited alarms - **Rich alarm editor**: Full-featured alarm creation and editing interface - **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability -- **Dynamic alarm sounds**: Configurable alarm sounds loaded from dedicated alarm-sounds.json configuration +- **Dynamic alarm sounds**: MP3 alarm sounds loaded from AlarmSounds folder - **Sound preview**: Play/stop functionality for testing alarm sounds before selection -- **Sound organization**: Alarm sounds organized in dedicated AlarmSounds.bundle with categories - **Custom labels**: User-defined alarm names and descriptions - **Repeat schedules**: Set alarms to repeat on specific weekdays or daily - **Sound selection**: Choose from extensive alarm sounds with live preview - **Volume control**: Adjustable alarm volume (0-100%) - **Vibration settings**: Enable/disable vibration for each alarm -- **Snooze functionality**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes) -- **Smart notifications**: Automatic scheduling for one-time and repeating alarms +- **Snooze functionality**: AlarmKit countdown feature for snooze support +- **Live Activity countdown**: Shows 5 minutes before alarm fires on Lock Screen and Dynamic Island +- **Dynamic Island**: Compact and expanded views with countdown timer +- **Lock Screen widget**: Full countdown display with alarm label - **Enable/disable toggles**: Individual alarm control with instant feedback -- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling -- **Background limitations**: Full alarm sound and screen require the app to be foregrounded; background alarms use notification sound -- **Inline alarm warnings**: Enabled alarms show a foreground-only warning when Keep Awake is off -- **Keep Awake prompt**: In-app popup enables Keep Awake without digging into settings -- **Keep Awake guidance**: Banner messaging explains why Keep Awake improves alarm reliability -- **Onboarding enablement**: Onboarding offers a one-tap Keep Awake enable action -- **Live Activity**: Dynamic Island/Lock Screen shows only while an alarm is ringing (user-enabled) -- **Live Activity availability**: Requires Live Activities permission in iOS Settings +- **AlarmKit authorization**: Requires user permission via NSAlarmKitUsageDescription - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility - **Alarm management**: Add, edit, delete, and duplicate alarms - **Next trigger preview**: Shows when the next alarm will fire - **Responsive time picker**: Font sizes adapt to available space and orientation +- **AlarmKitService**: Centralized service for AlarmKit integration - **AlarmSoundService integration**: Dedicated service for alarm-specific sound management - **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active -- **Foreground alarm sound**: In-app playback of the selected alarm sound and volume -- **Notification tap routing**: Tapping an alarm notification opens the alarm screen -- **Foreground handling**: Alarm notifications surface as in-app UI instead of banners +- **App Intents**: StopAlarmIntent and SnoozeAlarmIntent for Live Activity button actions ## Advanced Clock Display Features @@ -412,7 +406,7 @@ TheNoiseClock/ │ │ ├── Models/ │ │ │ └── SoundCategory.swift # Shared sound category definitions │ │ ├── LiveActivity/ -│ │ │ └── AlarmActivityAttributes.swift # Live Activity attributes shared with widget +│ │ │ └── NoiseClockAlarmMetadata.swift # AlarmKit metadata shared with widget │ │ └── Utilities/ │ │ ├── ColorUtils.swift # Color manipulation utilities │ │ ├── NotificationUtils.swift # Notification helper functions @@ -460,7 +454,7 @@ TheNoiseClock/ │ │ │ ├── Services/ │ │ │ │ ├── AlarmService.swift │ │ │ │ ├── AlarmSoundService.swift -│ │ │ │ ├── AlarmLiveActivityManager.swift +│ │ │ │ ├── AlarmKitService.swift # AlarmKit integration (iOS 26+) │ │ │ │ ├── FocusModeService.swift │ │ │ │ ├── NotificationService.swift │ │ │ │ └── NotificationDelegate.swift diff --git a/README.md b/README.md index 739e6eb..66a01b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TheNoiseClock -TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 18+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system. +TheNoiseClock is a SwiftUI iOS app that blends a bold, full-screen digital clock with white noise playback and a rich alarm system. It is optimized for iOS 26+ with Swift 6, built on a modular architecture, and styled with the Bedrock design system. Alarms use AlarmKit for reliable wake-up alerts that cut through Focus modes and silent mode. --- @@ -36,19 +36,16 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - Seamless looping with background audio support - Quick preview on long-press, instant play/stop controls -**Alarms** +**Alarms (Powered by AlarmKit)** - Unlimited alarms with labels, repeat schedules, and snooze options -- Alarm sound library with preview +- Alarm sound library with preview (MP3 format) - Vibration and volume controls per alarm -- Focus-mode aware scheduling +- AlarmKit integration: alarms cut through Focus modes and silent mode +- Live Activity countdown shows 5 minutes before alarm fires +- Dynamic Island displays countdown and alarm status +- Lock Screen shows alarm countdown with custom UI - Full-screen in-app alarm screen with Snooze/Stop when active -- In-app alarm sound playback using the selected alarm sound -- Tapping alarm notifications opens the alarm screen -- Background limitations: full alarm sound/screen requires the app to be open in the foreground -- Keep Awake prompt enables staying on-screen for alarms -- Enabled alarm rows show a foreground-only warning when Keep Awake is off -- Live Activity shows only while an alarm is ringing (user-enabled) -- Live Activity requires Live Activities permission in iOS Settings +- Snooze support via AlarmKit's countdown feature **Display Mode** - Long-press to enter immersive display mode @@ -78,8 +75,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and ## Requirements -- iOS 18.0+ -- Xcode 16+ +- iOS 26.0+ +- Xcode 26+ - Swift 6 --- diff --git a/TheNoiseClock.xcodeproj/project.pbxproj b/TheNoiseClock.xcodeproj/project.pbxproj index c2b52f1..1fccc17 100644 --- a/TheNoiseClock.xcodeproj/project.pbxproj +++ b/TheNoiseClock.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ EA384E832E6F806200CA7D50 /* AudioPlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA384D3D2E6F554D00CA7D50 /* AudioPlaybackKit */; }; EAC051B12F2E64AB007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC051B02F2E64AB007F87EA /* Bedrock */; }; EAF1C0DE2F3A4B5C0011223E /* TheNoiseClockWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,7 +57,6 @@ EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; }; EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TheNoiseClock/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; }; EAF1C0DE2F3A4B5C00112234 /* TheNoiseClockWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TheNoiseClockWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -169,7 +167,6 @@ children = ( EAD6E3AF5A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Debug.xcconfig */, EAD6E3B05A7F4D3DB37CF6D1 /* TheNoiseClock/Configuration/Release.xcconfig */, - EAF1C0DE2F3A4B5C00112241 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -378,7 +375,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - EAF1C0DE2F3A4B5C00112242 /* TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -542,7 +538,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 18; + IPHONEOS_DEPLOYMENT_TARGET = 26; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -576,7 +572,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 18; + IPHONEOS_DEPLOYMENT_TARGET = 26; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -691,7 +687,7 @@ DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TheNoiseClockWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18; + IPHONEOS_DEPLOYMENT_TARGET = 26; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -710,7 +706,7 @@ DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TheNoiseClockWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18; + IPHONEOS_DEPLOYMENT_TARGET = 26; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 79358ba..528733b 100644 --- a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ TheNoiseClock.xcscheme_^#shared#^_ orderHint - 3 + 2 TheNoiseClockWidget.xcscheme_^#shared#^_ orderHint - 2 + 3 diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index ed090c2..269b609 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -134,14 +134,19 @@ struct ContentView: View { } ) } - .onReceive(NotificationCenter.default.publisher(for: .alarmDidFire)) { notification in - alarmViewModel.handleAlarmNotification(userInfo: notification.userInfo) - } - .onReceive(NotificationCenter.default.publisher(for: .alarmDidStop)) { _ in - alarmViewModel.stopActiveAlarm() - } - .onReceive(NotificationCenter.default.publisher(for: .alarmDidSnooze)) { _ in - alarmViewModel.stopActiveAlarm() + // 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 guard onboardingState.hasCompletedWelcome else { return } diff --git a/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift b/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift new file mode 100644 index 0000000..bb5e959 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift @@ -0,0 +1,117 @@ +// +// AlarmIntents.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +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 alarm fires. +struct OpenAlarmAppIntent: LiveActivityIntent { + + static var title: LocalizedStringResource = "Open TheNoiseClock" + static var description = IntentDescription("Opens the app to the alarm screen") + + @Parameter(title: "Alarm ID") + var alarmID: String + + static var supportedModes: IntentModes { .foreground(.immediate) } + + init() { + self.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 + 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/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift new file mode 100644 index 0000000..fe9c840 --- /dev/null +++ b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift @@ -0,0 +1,285 @@ +// +// 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( + 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( + 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)" + } + } +} diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift b/TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift deleted file mode 100644 index a489628..0000000 --- a/TheNoiseClock/Features/Alarms/Services/AlarmLiveActivityManager.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// AlarmLiveActivityManager.swift -// TheNoiseClock -// -// Created by Matt Bruce on 2/2/26. -// - -import ActivityKit -import Bedrock -import Foundation - -@MainActor -final class AlarmLiveActivityManager { - private var currentActivity: Activity? - - func startOrUpdate(for alarm: Alarm) { - guard #available(iOS 16.1, *) else { return } - guard ActivityAuthorizationInfo().areActivitiesEnabled else { - Design.debugLog("[alarms] Live Activities not authorized") - return - } - - let attributes = AlarmActivityAttributes(id: alarm.id) - let alarmDate = alarm.nextTriggerTime() - let contentState = AlarmActivityAttributes.ContentState( - alarmDate: alarmDate, - label: alarm.label - ) - - if let activity = currentActivity { - if activity.attributes.id != alarm.id { - Task { - await activity.end(nil, dismissalPolicy: .immediate) - currentActivity = nil - startOrUpdate(for: alarm) - } - return - } else { - Task { - await activity.update(ActivityContent(state: contentState, staleDate: alarmDate)) - } - Design.debugLog("[alarms] Live Activity updated for \(alarm.label) at \(alarmDate)") - return - } - } - - do { - let activity = try Activity.request( - attributes: attributes, - content: ActivityContent(state: contentState, staleDate: alarmDate), - pushType: nil - ) - currentActivity = activity - Design.debugLog("[alarms] Live Activity started for \(alarm.label) at \(alarmDate)") - } catch { - Design.debugLog("[alarms] Live Activity request failed: \(error)") - } - } - - func endActivity() { - guard #available(iOS 16.1, *) else { return } - guard let activity = currentActivity else { return } - Task { - await activity.end(nil, dismissalPolicy: .immediate) - currentActivity = nil - Design.debugLog("[alarms] Live Activity ended") - } - } - - func refresh(for alarms: [Alarm], isEnabled: Bool) { - guard isEnabled else { - Design.debugLog("[alarms] Live Activities disabled in app settings") - endActivity() - return - } - guard let nextAlarm = alarms - .filter({ $0.isEnabled }) - .min(by: { $0.nextTriggerTime() < $1.nextTriggerTime() }) else { - Design.debugLog("[alarms] No enabled alarms; ending Live Activity") - endActivity() - return - } - Design.debugLog("[alarms] Live Activity refresh for \(nextAlarm.label)") - startOrUpdate(for: nextAlarm) - } -} diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift index 8f2cc3e..9f11a17 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift @@ -4,13 +4,17 @@ // // 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 UserNotifications import Observation import Bedrock -/// Service for managing alarms and notifications +/// Service for managing alarm persistence. +/// Alarm scheduling is handled by AlarmKitService. @Observable class AlarmService { @@ -18,48 +22,48 @@ class AlarmService { private(set) var alarms: [Alarm] = [] private var alarmLookup: [UUID: Int] = [:] private var persistenceWorkItem: DispatchWorkItem? - private let focusModeService = FocusModeService.shared // MARK: - Initialization init() { loadAlarms() - Task { - // Request permissions through FocusModeService for better compatibility - let granted = await focusModeService.requestNotificationPermissions() - if !granted { - // Fallback to original method - _ = await NotificationUtils.requestPermissions() - } - } + 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() - scheduleNotification(for: alarm) saveAlarms() } + /// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService. func updateAlarm(_ alarm: Alarm) { - guard let index = alarmLookup[alarm.id] else { return } + 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() - scheduleNotification(for: alarm) saveAlarms() } + /// 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() - NotificationUtils.removeNotification(identifier: id.uuidString) saveAlarms() } + /// 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() - scheduleNotification(for: alarms[index]) + Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)") saveAlarms() } @@ -75,50 +79,6 @@ class AlarmService { } } - private func scheduleNotification(for alarm: Alarm) { - // Remove existing notification - NotificationUtils.removeNotification(identifier: alarm.id.uuidString) - - // Schedule new notification if enabled - if alarm.isEnabled { - Task { - let respectFocusModes = currentRespectFocusModes() - let liveActivitiesEnabled = isLiveActivitiesEnabled() - let body = liveActivitiesEnabled ? "" : alarm.notificationMessage - Design.debugLog("[alarms] AlarmService schedule \(alarm.label). LiveActivities=\(liveActivitiesEnabled) body=\(body.isEmpty ? "" : "present")") - // Use FocusModeService for better Focus mode compatibility - focusModeService.scheduleAlarmNotification( - identifier: alarm.id.uuidString, - title: alarm.label, - body: body, - date: alarm.time, - soundName: alarm.soundName, - repeats: false, // For now, set to false since Alarm model doesn't have repeatDays - respectFocusModes: respectFocusModes, - snoozeDuration: alarm.snoozeDuration, - isVibrationEnabled: alarm.isVibrationEnabled, - volume: alarm.volume - ) - } - } - } - - private func currentRespectFocusModes() -> Bool { - guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey), - let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else { - return ClockStyle().respectFocusModes - } - return style.respectFocusModes - } - - private func isLiveActivitiesEnabled() -> Bool { - guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey), - let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else { - return ClockStyle().liveActivitiesEnabled - } - return style.liveActivitiesEnabled - } - private func saveAlarms() { persistenceWorkItem?.cancel() @@ -141,11 +101,13 @@ class AlarmService { let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) { alarms = decodedAlarms updateAlarmLookup() - - // Reschedule all enabled alarms - for alarm in alarms where alarm.isEnabled { - scheduleNotification(for: alarm) - } + Design.debugLog("[alarms] Loaded \(alarms.count) alarms from storage") + // Note: AlarmKit scheduling is handled by AlarmViewModel.rescheduleAllAlarms() } } + + /// Get all enabled alarms (for rescheduling with AlarmKit) + func getEnabledAlarms() -> [Alarm] { + return alarms.filter { $0.isEnabled } + } } diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift index 2929c9e..3013013 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift @@ -119,4 +119,67 @@ class AlarmSoundService { func getAlarmSound(fileName: String) -> Sound? { return getAlarmSounds().first { $0.fileName == fileName } } + + /// Get the file path URL for a sound by filename + /// - Parameter fileName: The sound filename (e.g., "classic-alarm.mp3") + /// - Returns: The file URL if found, nil otherwise + func getSoundPath(for fileName: String) -> URL? { + Design.debugLog("[audio] Looking for sound file: \(fileName)") + + // Try AlarmSounds.bundle first + if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"), + let alarmBundle = Bundle(url: bundleURL) { + + // Try with full filename + let nameWithoutExtension = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + + if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) { + Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle: \(url)") + return url + } + Design.debugLog("[audio] Not found in AlarmSounds.bundle with extension '\(ext)'") + + // Try common extensions + for tryExt in ["mp3", "caf", "wav", "m4a"] { + if let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: tryExt) { + Design.debugLog("[audio] ✅ Found in AlarmSounds.bundle with .\(tryExt): \(url)") + return url + } + } + } + + // Try AlarmSounds folder in main bundle + let nameWithoutExtension = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + + if let url = Bundle.main.url(forResource: "AlarmSounds/\(nameWithoutExtension)", withExtension: ext) { + Design.debugLog("[audio] ✅ Found in AlarmSounds folder: \(url)") + return url + } + + // Try main bundle directly + if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) { + Design.debugLog("[audio] ✅ Found in main bundle: \(url)") + return url + } + + Design.debugLog("[audio] ❌ Sound file not found: \(fileName)") + return nil + } + + /// Log all available alarm sounds + func logAvailableSounds() { + Design.debugLog("[audio] ========== AVAILABLE ALARM SOUNDS ==========") + let sounds = getAlarmSounds() + Design.debugLog("[audio] Found \(sounds.count) alarm sound(s)") + for sound in sounds { + Design.debugLog("[audio] - \(sound.name): \(sound.fileName)") + if let path = getSoundPath(for: sound.fileName) { + Design.debugLog("[audio] Path: \(path.lastPathComponent)") + } else { + Design.debugLog("[audio] ⚠️ FILE NOT FOUND") + } + } + } } diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index ea179f7..069cef0 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -5,26 +5,31 @@ // Created by Matt Bruce on 9/7/25. // -import Foundation -import Observation -import UserNotifications +import AlarmKit import AudioPlaybackKit import Bedrock -import Bedrock +import Foundation +import Observation -/// ViewModel for alarm management +/// 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. @Observable class AlarmViewModel { // MARK: - Properties private let alarmService: AlarmService - private let notificationService: NotificationService + private let alarmKitService = AlarmKitService.shared private let alarmSoundService = AlarmSoundService.shared private let soundPlayer = SoundPlayer.shared - private let liveActivityManager = AlarmLiveActivityManager() var activeAlarm: Alarm? + /// Whether AlarmKit is authorized + var isAlarmKitAuthorized: Bool { + alarmKitService.authorizationState == .authorized + } + var alarms: [Alarm] { alarmService.alarms } @@ -34,55 +39,51 @@ class AlarmViewModel { } // MARK: - Initialization - init(alarmService: AlarmService = AlarmService(), - notificationService: NotificationService = NotificationService()) { + init(alarmService: AlarmService = AlarmService()) { self.alarmService = alarmService - self.notificationService = notificationService - - // Register alarm service with notification delegate for snooze handling - NotificationDelegate.shared.setAlarmService(alarmService) + } + + // MARK: - Authorization + + /// Request AlarmKit authorization. Should be called during onboarding. + func requestAlarmKitAuthorization() async -> Bool { + return await alarmKitService.requestAuthorization() } // MARK: - Public Interface func addAlarm(_ alarm: Alarm) async { alarmService.addAlarm(alarm) - // Schedule notification if alarm is enabled + // Schedule with AlarmKit if alarm is enabled if alarm.isEnabled { - Design.debugLog("[alarms] Scheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())") - await notificationService.scheduleAlarmNotification( - id: alarm.id.uuidString, - title: alarm.label, - body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage, - soundName: alarm.soundName, - date: alarm.time - ) - requestKeepAwakePromptIfNeeded() + Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)") + do { + try await alarmKitService.scheduleAlarm(alarm) + } catch { + Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)") + } } } func updateAlarm(_ alarm: Alarm) async { alarmService.updateAlarm(alarm) - // Reschedule notification + // Cancel existing and reschedule if enabled + alarmKitService.cancelAlarm(id: alarm.id) + if alarm.isEnabled { - Design.debugLog("[alarms] Rescheduling alarm notification for \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())") - await notificationService.scheduleAlarmNotification( - id: alarm.id.uuidString, - title: alarm.label, - body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage, - soundName: alarm.soundName, - date: alarm.time - ) - requestKeepAwakePromptIfNeeded() - } else { - notificationService.cancelNotification(id: alarm.id.uuidString) + Design.debugLog("[alarms] Rescheduling AlarmKit alarm for \(alarm.label)") + do { + try await alarmKitService.scheduleAlarm(alarm) + } catch { + Design.debugLog("[alarms] AlarmKit rescheduling failed: \(error)") + } } } func deleteAlarm(id: UUID) async { - // Cancel notification first - notificationService.cancelNotification(id: id.uuidString) + // Cancel AlarmKit alarm first + alarmKitService.cancelAlarm(id: id) // Then delete from storage alarmService.deleteAlarm(id: id) @@ -94,19 +95,16 @@ class AlarmViewModel { alarm.isEnabled.toggle() alarmService.updateAlarm(alarm) - // Schedule or cancel notification based on new state + // Schedule or cancel based on new state if alarm.isEnabled { - Design.debugLog("[alarms] Enabling alarm \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())") - await notificationService.scheduleAlarmNotification( - id: alarm.id.uuidString, - title: alarm.label, - body: isLiveActivitiesEnabled() ? "" : alarm.notificationMessage, - soundName: alarm.soundName, - date: alarm.time - ) - requestKeepAwakePromptIfNeeded() + Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)") + do { + try await alarmKitService.scheduleAlarm(alarm) + } catch { + Design.debugLog("[alarms] AlarmKit scheduling failed: \(error)") + } } else { - notificationService.cancelNotification(id: id.uuidString) + alarmKitService.cancelAlarm(id: id) } } @@ -137,182 +135,134 @@ class AlarmViewModel { 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 } - Design.debugLog("[alarms] handleAlarmNotification userInfo keys: \(Array(userInfo.keys))") - if let alarm = resolveAlarm(from: userInfo) { - startActiveAlarm(alarm) - } - } - + /// Stop an active alarm using AlarmKit. + /// - Parameter id: The UUID of the alarm to stop. @MainActor - func stopActiveAlarm() { + func stopAlarm(id: UUID) { + alarmKitService.stopAlarm(id: id) + + // Also stop any in-app sound if playing soundPlayer.stopSound() - if let alarm = activeAlarm, let stored = alarmService.getAlarm(id: alarm.id) { + + // 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) - notificationService.cancelNotification(id: updated.id.uuidString) } + activeAlarm = nil - liveActivityManager.endActivity() + 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 } - scheduleSnoozeNotification(for: alarm) - stopActiveAlarm() + snoozeAlarm(id: alarm.id) } - @MainActor - private func startActiveAlarm(_ alarm: Alarm) { - if activeAlarm?.id == alarm.id { - return - } - activeAlarm = alarm - playAlarmSound(for: alarm) - Design.debugLog("[alarms] Alarm fired: \(alarm.label). LiveActivities=\(isLiveActivitiesEnabled())") - if isLiveActivitiesEnabled() { - liveActivityManager.startOrUpdate(for: alarm) - } - } + // MARK: - AlarmKit Updates - 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 isLiveActivitiesEnabled() -> Bool { - guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey), - let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else { - return ClockStyle().liveActivitiesEnabled - } - return style.liveActivitiesEnabled - } - - 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 - ) - + /// 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 { - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - // Intentionally silent; notification system logs errors + for await alarms in alarmKitService.alarmUpdates { + await handleAlarmUpdates(alarms) } } } - - 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 + @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 + + /// Reschedule all enabled alarms with AlarmKit. + /// Call this on app launch to ensure alarms are registered. + func rescheduleAllAlarms() async { + Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========") + + let enabledAlarms = alarmService.getEnabledAlarms() + Design.debugLog("[alarmkit] Found \(enabledAlarms.count) enabled alarm(s)") + + for alarm in enabledAlarms { + Design.debugLog("[alarmkit] Scheduling: \(alarm.label) at \(alarm.time)") + do { + try await alarmKitService.scheduleAlarm(alarm) + } catch { + Design.debugLog("[alarmkit] ❌ Failed to reschedule \(alarm.label): \(error)") + } } - 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 + Design.debugLog("[alarmkit] ========== RESCHEDULING COMPLETE ==========") + alarmKitService.logCurrentAlarms() } } diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift b/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift index a694a80..7c309fd 100644 --- a/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift +++ b/TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift @@ -57,7 +57,7 @@ struct AlarmScreen: View { #Preview { AlarmScreen( - alarm: Alarm(time: Date(), soundName: "digital-alarm.caf", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0), + 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/TheNoiseClock/Features/Alarms/Views/AlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift index f62bd05..e722f81 100644 --- a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift @@ -83,9 +83,9 @@ struct AlarmView: View { } .onAppear { Task { - await viewModel.requestNotificationPermissions() + // Request AlarmKit authorization when the alarms tab appears + await viewModel.requestAlarmKitAuthorization() } - viewModel.requestKeepAwakePromptIfNeeded() } .sheet(isPresented: $showAddAlarm) { AddAlarmView( diff --git a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift index e477f74..d9d36e8 100644 --- a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift +++ b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift @@ -3,9 +3,12 @@ // TheNoiseClock // // Streamlined onboarding flow optimized for time-to-value. -// Shows real clock immediately, requests permissions with context, +// Shows real clock immediately, requests AlarmKit permission, // and gets users to their "aha moment" fast. // +// Updated for AlarmKit (iOS 26+) - alarms now cut through +// Focus modes and silent mode automatically. +// import SwiftUI import Bedrock @@ -19,10 +22,9 @@ struct OnboardingView: View { let onComplete: () -> Void @State private var currentPage = 0 - @State private var notificationPermissionGranted = false - @State private var showCelebration = false + @State private var alarmKitPermissionGranted = false @State private var keepAwakeEnabled = false - @State private var liveActivitiesEnabled = false + @State private var showCelebration = false private let totalPages = 3 @@ -124,84 +126,49 @@ struct OnboardingView: View { .padding(.horizontal, Design.Spacing.xxLarge) } - // MARK: - Page 2: Permissions (Contextual) + // MARK: - Page 2: AlarmKit Permissions private var permissionsPage: some View { VStack(spacing: Design.Spacing.xxLarge) { Spacer() - // Alarm icon with context + // Alarm icon with animated waves ZStack { Circle() .fill(AppAccent.primary.opacity(0.15)) .frame(width: 120, height: 120) - Image(systemName: "alarm.fill") + Image(systemName: "alarm.waves.left.and.right.fill") .font(.system(size: 50, weight: .medium)) .foregroundStyle(AppAccent.primary) + .symbolEffect(.pulse, options: .repeating) } - Text("Never miss an alarm") + Text("Alarms that actually work") .typography(.heroBold) .foregroundStyle(AppTextColors.primary) .multilineTextAlignment(.center) - Text("Enable notifications so alarms work even when the app is closed or your phone is locked.") + Text("Works in silent mode, Focus mode, and even when your phone is locked.") .typography(.body) .foregroundStyle(AppTextColors.secondary) .multilineTextAlignment(.center) .padding(.horizontal, Design.Spacing.xxLarge) - VStack(spacing: Design.Spacing.small) { - Text("For reliable alarms, keep TheNoiseClock on-screen.") - .typography(.body) - .foregroundStyle(AppTextColors.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, Design.Spacing.xxLarge) - - Button { - enableKeepAwake() - } label: { - HStack { - Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill") - Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake") - } - .typography(.bodyEmphasis) - .foregroundStyle(keepAwakeEnabled ? AppStatus.success : .white) - .frame(maxWidth: 280) - .padding(Design.Spacing.medium) - .background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary) - .cornerRadius(Design.CornerRadius.medium) - } - .disabled(keepAwakeEnabled) - } - - VStack(spacing: Design.Spacing.small) { - Text("Show alarms on the Dynamic Island and Lock Screen while they are ringing.") - .typography(.body) - .foregroundStyle(AppTextColors.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, Design.Spacing.xxLarge) - - Button { - enableLiveActivities() - } label: { - HStack { - Image(systemName: liveActivitiesEnabled ? "checkmark.circle.fill" : "sparkles") - Text(liveActivitiesEnabled ? "Live Activities Enabled" : "Enable Live Activities") - } - .typography(.bodyEmphasis) - .foregroundStyle(liveActivitiesEnabled ? AppStatus.success : .white) - .frame(maxWidth: 280) - .padding(Design.Spacing.medium) - .background(liveActivitiesEnabled ? AppStatus.success.opacity(0.15) : AppAccent.primary) - .cornerRadius(Design.CornerRadius.medium) - } - .disabled(liveActivitiesEnabled) + // Feature bullets + VStack(alignment: .leading, spacing: Design.Spacing.small) { + alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb") + alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen") + alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed") } + .padding(.top, Design.Spacing.medium) // Permission button or success state permissionButton + .padding(.top, Design.Spacing.large) + + // Optional: Keep Awake for bedside clock mode + keepAwakeSection .padding(.top, Design.Spacing.medium) Spacer() @@ -210,18 +177,58 @@ struct OnboardingView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { keepAwakeEnabled = isKeepAwakeEnabled() - liveActivitiesEnabled = isLiveActivitiesEnabled() } } + private var keepAwakeSection: some View { + VStack(spacing: Design.Spacing.small) { + Text("Want the clock always visible?") + .typography(.callout) + .foregroundStyle(AppTextColors.tertiary) + .multilineTextAlignment(.center) + + Button { + enableKeepAwake() + } label: { + HStack(spacing: Design.Spacing.small) { + Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill") + Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake") + } + .typography(.callout) + .foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary) + .cornerRadius(Design.CornerRadius.medium) + } + .disabled(keepAwakeEnabled) + } + } + + private func alarmFeatureRow(icon: String, text: String) -> some View { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundStyle(AppAccent.primary) + .frame(width: 28) + + Text(text) + .typography(.body) + .foregroundStyle(AppTextColors.secondary) + + Spacer() + } + .padding(.horizontal, Design.Spacing.xxLarge) + } + private var permissionButton: some View { Group { - if notificationPermissionGranted { + if alarmKitPermissionGranted { // Success state HStack(spacing: Design.Spacing.small) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 24)) - Text("You're all set!") + Text("Alarms enabled!") } .foregroundStyle(AppStatus.success) .typography(.bodyEmphasis) @@ -229,13 +236,13 @@ struct OnboardingView: View { .background(AppStatus.success.opacity(0.15)) .cornerRadius(Design.CornerRadius.medium) } else { - // Request button + // Request AlarmKit authorization Button { - requestNotificationPermission() + requestAlarmKitPermission() } label: { HStack { - Image(systemName: "bell.badge.fill") - Text("Enable Notifications") + Image(systemName: "alarm.fill") + Text("Enable Alarms") } .typography(.bodyEmphasis) .foregroundStyle(.white) @@ -269,7 +276,7 @@ struct OnboardingView: View { .typography(.heroBold) .foregroundStyle(AppTextColors.primary) - Text("Tap below to start using your new bedside clock. Try long-pressing the clock for immersive mode!") + Text("Your alarms will work even in silent mode and Focus mode. Try long-pressing the clock for immersive mode!") .typography(.body) .foregroundStyle(AppTextColors.secondary) .multilineTextAlignment(.center) @@ -277,9 +284,9 @@ struct OnboardingView: View { // Quick tips VStack(alignment: .leading, spacing: Design.Spacing.small) { + tipRow(icon: "alarm.fill", text: "Create your first alarm") tipRow(icon: "hand.tap", text: "Long-press clock for full screen") tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds") - tipRow(icon: "plus", text: "Tap + on Alarms to add one") } .padding(.top, Design.Spacing.medium) @@ -384,11 +391,12 @@ struct OnboardingView: View { // MARK: - Actions - private func requestNotificationPermission() { + private func requestAlarmKitPermission() { Task { - let granted = await NotificationUtils.requestPermissions() + // Request AlarmKit authorization (iOS 26+) + let granted = await AlarmKitService.shared.requestAuthorization() withAnimation(.spring(duration: 0.3)) { - notificationPermissionGranted = granted + alarmKitPermissionGranted = granted } // Auto-advance after permission granted if granted { @@ -399,7 +407,7 @@ struct OnboardingView: View { } } } - + private func enableKeepAwake() { var style = loadClockStyle() style.keepAwake = true @@ -409,24 +417,10 @@ struct OnboardingView: View { keepAwakeEnabled = true } } - - private func enableLiveActivities() { - var style = loadClockStyle() - style.liveActivitiesEnabled = true - saveClockStyle(style) - NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) - withAnimation(.spring(duration: 0.3)) { - liveActivitiesEnabled = true - } - } private func isKeepAwakeEnabled() -> Bool { loadClockStyle().keepAwake } - - private func isLiveActivitiesEnabled() -> Bool { - loadClockStyle().liveActivitiesEnabled - } private func loadClockStyle() -> ClockStyle { guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey), diff --git a/TheNoiseClock/Info.plist b/TheNoiseClock/Info.plist index 8224692..6656aed 100644 --- a/TheNoiseClock/Info.plist +++ b/TheNoiseClock/Info.plist @@ -14,5 +14,7 @@ $(APPCLIP_DOMAIN) NSSupportsLiveActivities + NSAlarmKitUsageDescription + TheNoiseClock uses alarms to wake you up at your scheduled time, even when your device is in silent mode or Focus mode. diff --git a/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json b/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json index 82c6082..ffe4424 100644 --- a/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json +++ b/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json @@ -3,7 +3,7 @@ { "id": "digital-alarm", "name": "Digital Alarm", - "fileName": "digital-alarm.caf", + "fileName": "digital-alarm.mp3", "description": "Classic digital alarm sound", "category": "alarm", "bundleName": null, @@ -12,7 +12,7 @@ { "id": "buzzing-alarm", "name": "Buzzing Alarm", - "fileName": "buzzing-alarm.caf", + "fileName": "buzzing-alarm.mp3", "description": "Buzzing sound for gentle wake-up", "category": "alarm", "bundleName": null @@ -20,7 +20,7 @@ { "id": "classic-alarm", "name": "Classic Alarm", - "fileName": "classic-alarm.caf", + "fileName": "classic-alarm.mp3", "description": "Traditional alarm sound", "category": "alarm", "bundleName": null @@ -28,7 +28,7 @@ { "id": "beep-alarm", "name": "Beep Alarm", - "fileName": "beep-alarm.caf", + "fileName": "beep-alarm.mp3", "description": "Short beep alarm sound", "category": "alarm", "bundleName": null @@ -36,7 +36,7 @@ { "id": "siren-alarm", "name": "Siren Alarm", - "fileName": "siren-alarm.caf", + "fileName": "siren-alarm.mp3", "description": "Emergency siren alarm for heavy sleepers", "category": "alarm", "bundleName": null diff --git a/TheNoiseClock/Resources/AlarmSounds/beep-alarm.caf b/TheNoiseClock/Resources/AlarmSounds/beep-alarm.caf deleted file mode 100644 index d634973..0000000 Binary files a/TheNoiseClock/Resources/AlarmSounds/beep-alarm.caf and /dev/null differ diff --git a/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 new file mode 100644 index 0000000..83dc7ae Binary files /dev/null and b/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.caf b/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.caf deleted file mode 100644 index 17de145..0000000 Binary files a/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.caf and /dev/null differ diff --git a/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 new file mode 100644 index 0000000..c36aa69 Binary files /dev/null and b/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/classic-alarm.caf b/TheNoiseClock/Resources/AlarmSounds/classic-alarm.caf deleted file mode 100644 index bec46fe..0000000 Binary files a/TheNoiseClock/Resources/AlarmSounds/classic-alarm.caf and /dev/null differ diff --git a/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 new file mode 100644 index 0000000..dc61b0e Binary files /dev/null and b/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/digital-alarm.caf b/TheNoiseClock/Resources/AlarmSounds/digital-alarm.caf deleted file mode 100644 index 2a1d78e..0000000 Binary files a/TheNoiseClock/Resources/AlarmSounds/digital-alarm.caf and /dev/null differ diff --git a/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 new file mode 100644 index 0000000..57ffd9e Binary files /dev/null and b/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/siren-alarm.caf b/TheNoiseClock/Resources/AlarmSounds/siren-alarm.caf deleted file mode 100644 index a7bb46e..0000000 Binary files a/TheNoiseClock/Resources/AlarmSounds/siren-alarm.caf and /dev/null differ diff --git a/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 new file mode 100644 index 0000000..2553762 Binary files /dev/null and b/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 differ diff --git a/TheNoiseClock/Shared/Design/AppConstants.swift b/TheNoiseClock/Shared/Design/AppConstants.swift index 63def5a..984f0b3 100644 --- a/TheNoiseClock/Shared/Design/AppConstants.swift +++ b/TheNoiseClock/Shared/Design/AppConstants.swift @@ -12,7 +12,7 @@ enum AppConstants { // MARK: - App Information static let appName = "TheNoiseClock" - static let minimumIOSVersion = "18.0" + static let minimumIOSVersion = "26.0" // MARK: - Storage Keys enum StorageKeys { @@ -59,7 +59,7 @@ enum AppConstants { // MARK: - System Sounds enum SystemSounds { - static let defaultSound = "digital-alarm.caf" + static let defaultSound = "digital-alarm.mp3" static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"] } } diff --git a/TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift b/TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift deleted file mode 100644 index a805554..0000000 --- a/TheNoiseClock/Shared/LiveActivity/AlarmActivityAttributes.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AlarmActivityAttributes.swift -// TheNoiseClock -// -// Created by Matt Bruce on 2/2/26. -// - -import ActivityKit -import Foundation - -struct AlarmActivityAttributes: ActivityAttributes { - struct ContentState: Codable, Hashable { - var alarmDate: Date - var label: String - } - - var id: UUID -} diff --git a/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift b/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift new file mode 100644 index 0000000..9a6e9cd --- /dev/null +++ b/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift @@ -0,0 +1,28 @@ +// +// NoiseClockAlarmMetadata.swift +// TheNoiseClock +// +// Created by Matt Bruce on 2/2/26. +// + +import AlarmKit +import Foundation + +/// Metadata for alarm Live Activities, shared between app and widget extension. +/// Must conform to AlarmMetadata and be nonisolated for cross-actor use. +nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata { + /// The unique identifier for the alarm + var alarmId: String + + /// The sound file name to play when the alarm fires + var soundName: String + + /// The snooze duration in minutes + var snoozeDuration: Int + + /// The alarm label to display + var label: String + + /// Volume level (0.0 to 1.0) + var volume: Float +} diff --git a/TheNoiseClockWidget/AlarmLiveActivityWidget.swift b/TheNoiseClockWidget/AlarmLiveActivityWidget.swift index b775693..fbb9d69 100644 --- a/TheNoiseClockWidget/AlarmLiveActivityWidget.swift +++ b/TheNoiseClockWidget/AlarmLiveActivityWidget.swift @@ -5,51 +5,175 @@ // Created by Matt Bruce on 2/2/26. // -import ActivityKit +import AlarmKit import SwiftUI import WidgetKit +/// Live Activity widget for alarm countdown and alerting states. +/// Uses AlarmKit's AlarmAttributes for automatic countdown management. struct AlarmLiveActivityWidget: Widget { var body: some WidgetConfiguration { - ActivityConfiguration(for: AlarmActivityAttributes.self) { context in - VStack(spacing: 8) { - Text("Next Alarm") - .font(.caption) - .foregroundStyle(.secondary) - Text(context.state.label) - .font(.headline) - Text(context.state.alarmDate, style: .time) - .font(.title2.weight(.bold)) - } - .padding() + ActivityConfiguration(for: AlarmAttributes.self) { context in + // Lock Screen presentation + LockScreenAlarmView( + attributes: context.attributes, + state: context.state + ) } dynamicIsland: { context in DynamicIsland { + // Expanded regions DynamicIslandExpandedRegion(.leading) { - Text("Alarm") - .font(.caption) - .foregroundStyle(.secondary) + if let metadata = context.attributes.metadata { + AlarmTitleView(metadata: metadata) + } else { + Text("Alarm") + .font(.caption) + } } DynamicIslandExpandedRegion(.trailing) { - Text(context.state.alarmDate, style: .time) - .font(.caption2) - } - DynamicIslandExpandedRegion(.center) { - Text(context.state.label) - .font(.headline) + AlarmProgressView(state: context.state) } DynamicIslandExpandedRegion(.bottom) { - Text("Alarm at \(context.state.alarmDate, style: .time)") - .font(.caption) + ExpandedAlarmView( + attributes: context.attributes, + state: context.state + ) } } compactLeading: { - Image(systemName: "alarm") + // Compact leading - countdown text + CountdownTextView(state: context.state) + .foregroundStyle(context.attributes.tintColor) } compactTrailing: { - Text(context.state.alarmDate, style: .time) - .font(.caption2) + // Compact trailing - progress ring + AlarmProgressView(state: context.state) + .frame(maxWidth: 32) } minimal: { - Image(systemName: "alarm") + // Minimal - just an alarm icon + Image(systemName: "alarm.fill") + .foregroundStyle(context.attributes.tintColor) } } } } +// MARK: - Lock Screen View + +struct LockScreenAlarmView: View { + let attributes: AlarmAttributes + let state: AlarmPresentationState + + private var alarmLabel: String { + attributes.metadata?.label ?? "Alarm" + } + + var body: some View { + VStack(spacing: 12) { + // Alarm label + Text(alarmLabel) + .font(.headline) + .foregroundStyle(.primary) + + // Countdown state + if case .countdown(let countdown) = state.mode { + VStack(spacing: 4) { + Text("Alarm in") + .font(.caption) + .foregroundStyle(.secondary) + Text(timerInterval: Date.now...countdown.fireDate, countsDown: true) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(attributes.tintColor) + } + } else if case .paused = state.mode { + Text("Paused") + .font(.title3) + .foregroundStyle(.secondary) + } else { + // Other states (alerting, etc.) + VStack(spacing: 4) { + Image(systemName: "alarm.waves.left.and.right.fill") + .font(.system(size: 32)) + .foregroundStyle(attributes.tintColor) + .symbolEffect(.pulse) + Text("Alarm Ringing") + .font(.title3.weight(.semibold)) + } + } + } + .padding() + .frame(maxWidth: .infinity) + } +} + +// MARK: - Expanded Dynamic Island View + +struct ExpandedAlarmView: View { + let attributes: AlarmAttributes + let state: AlarmPresentationState + + var body: some View { + HStack { + CountdownTextView(state: state) + .font(.headline) + + Spacer() + + AlarmProgressView(state: state) + .frame(maxHeight: 30) + } + } +} + +// MARK: - Countdown Text View + +struct CountdownTextView: View { + let state: AlarmPresentationState + + var body: some View { + if case .countdown(let countdown) = state.mode { + Text(timerInterval: Date.now...countdown.fireDate, countsDown: true) + .monospacedDigit() + .lineLimit(1) + } else if case .paused = state.mode { + Text("Paused") + } else { + Text("Now!") + .fontWeight(.bold) + } + } +} + +// MARK: - Progress View + +struct AlarmProgressView: View { + let state: AlarmPresentationState + + var body: some View { + if case .countdown(let countdown) = state.mode { + ProgressView( + timerInterval: Date.now...countdown.fireDate, + label: { EmptyView() }, + currentValueLabel: { Text("") } + ) + .progressViewStyle(.circular) + } else if case .paused = state.mode { + Image(systemName: "pause.fill") + } else { + Image(systemName: "alarm.waves.left.and.right.fill") + .symbolEffect(.pulse) + } + } +} + +// MARK: - Title View + +struct AlarmTitleView: View { + let metadata: NoiseClockAlarmMetadata + + var body: some View { + Text(metadata.label) + .font(.caption) + .lineLimit(1) + } +} + diff --git a/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift b/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift new file mode 100644 index 0000000..1c0681e --- /dev/null +++ b/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift @@ -0,0 +1,32 @@ +// +// NoiseClockAlarmMetadata.swift +// TheNoiseClockWidget +// +// Created by Matt Bruce on 2/2/26. +// +// NOTE: This file must be kept in sync with the main app's version. +// In Xcode, add the main app's NoiseClockAlarmMetadata.swift to both targets +// and remove this file, or use a shared Swift package. +// + +import AlarmKit +import Foundation + +/// Metadata for alarm Live Activities, shared between app and widget extension. +/// Must conform to AlarmMetadata and be nonisolated for cross-actor use. +nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata { + /// The unique identifier for the alarm + var alarmId: String + + /// The sound file name to play when the alarm fires + var soundName: String + + /// The snooze duration in minutes + var snoozeDuration: Int + + /// The alarm label to display + var label: String + + /// Volume level (0.0 to 1.0) + var volume: Float +}