diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift index 726a809..71759f4 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift @@ -95,7 +95,8 @@ public class SoundConfigurationService { /// Load sound configuration from multiple category-specific JSON files public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration { - let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient"] + // Include AlarmSounds bundle for alarm sound preview functionality + let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"] var allSounds: [Sound] = [] for bundleName in bundleNames { diff --git a/PRD.md b/PRD.md index d53b9a2..1033e39 100644 --- a/PRD.md +++ b/PRD.md @@ -452,17 +452,15 @@ TheNoiseClock/ │ │ │ ├── State/ │ │ │ │ └── AlarmViewModel.swift │ │ │ ├── Services/ -│ │ │ │ ├── AlarmService.swift -│ │ │ │ ├── AlarmSoundService.swift -│ │ │ │ ├── AlarmKitService.swift # AlarmKit integration (iOS 26+) -│ │ │ │ ├── FocusModeService.swift -│ │ │ │ ├── NotificationService.swift -│ │ │ │ └── NotificationDelegate.swift +│ │ │ │ ├── AlarmService.swift # Alarm persistence +│ │ │ │ ├── AlarmSoundService.swift # Alarm sound metadata +│ │ │ │ └── AlarmKitService.swift # AlarmKit integration (iOS 26+) +│ │ │ ├── Intents/ +│ │ │ │ └── AlarmIntents.swift # App Intents for Stop/Snooze │ │ │ └── Views/ │ │ │ ├── AlarmView.swift │ │ │ ├── AddAlarmView.swift │ │ │ ├── EditAlarmView.swift -│ │ │ ├── AlarmScreen.swift │ │ │ └── Components/ │ │ │ ├── AlarmRowView.swift │ │ │ ├── EmptyAlarmsView.swift diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift index fdb7d3b..1bc4d0e 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift @@ -88,14 +88,28 @@ final class AlarmKitService { textColor: .red, systemImageName: "stop.fill" ) - Design.debugLog("[alarmkit] Created stop button") - // Create the alert presentation (sound is in AlarmConfiguration, not here) - let alert = AlarmPresentation.Alert( - title: LocalizedStringResource(stringLiteral: alarm.label), - stopButton: stopButton + // Create the snooze button (secondary button with countdown behavior) + let snoozeButton = AlarmButton( + text: "Snooze", + textColor: .white, + systemImageName: "moon.zzz" ) - Design.debugLog("[alarmkit] Created alert presentation") + Design.debugLog("[alarmkit] Created stop and snooze buttons") + + // Create the alert presentation with snooze as secondary button + // secondaryButtonBehavior: .countdown enables snooze functionality + // Include both label and notification message in the title + let alertTitle = alarm.notificationMessage.isEmpty + ? alarm.label + : "\(alarm.label) - \(alarm.notificationMessage)" + let alert = AlarmPresentation.Alert( + title: LocalizedStringResource(stringLiteral: alertTitle), + stopButton: stopButton, + secondaryButton: snoozeButton, + secondaryButtonBehavior: .countdown + ) + Design.debugLog("[alarmkit] Created alert with title: \(alertTitle)") // Create metadata for the alarm let metadata = NoiseClockAlarmMetadata( @@ -103,9 +117,10 @@ final class AlarmKitService { soundName: alarm.soundName, snoozeDuration: alarm.snoozeDuration, label: alarm.label, + message: alarm.notificationMessage, volume: alarm.volume ) - Design.debugLog("[alarmkit] Created metadata: alarmId=\(metadata.alarmId), sound=\(metadata.soundName)") + Design.debugLog("[alarmkit] Created metadata: alarmId=\(metadata.alarmId), sound=\(metadata.soundName), message=\(metadata.message)") // Create alarm attributes let attributes = AlarmAttributes( @@ -119,12 +134,15 @@ final class AlarmKitService { let schedule = createSchedule(for: alarm) Design.debugLog("[alarmkit] Created 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") + // CountdownDuration for snooze support: + // - preAlert: nil = no countdown before alarm (fires immediately at scheduled time) + // - postAlert: snooze duration (how long until alarm fires again after snooze) + // If no snooze, set countdownDuration to nil + let snoozeDurationSeconds = TimeInterval(alarm.snoozeDuration * 60) + let countdownDuration: AlarmKit.Alarm.CountdownDuration? = snoozeDurationSeconds > 0 + ? AlarmKit.Alarm.CountdownDuration(preAlert: nil, postAlert: snoozeDurationSeconds) + : nil + Design.debugLog("[alarmkit] Countdown duration: preAlert=nil (immediate), postAlert=\(snoozeDurationSeconds)s (snooze)") // Create the sound let soundName = getSoundNameForAlarmKit(alarm.soundName) @@ -157,54 +175,138 @@ final class AlarmKitService { // MARK: - Sound Configuration - /// Get the sound name for AlarmKit (without extension) + /// Get the sound name for AlarmKit and ensure it's in Library/Sounds + /// AlarmKit can only play sounds from the main bundle root or Library/Sounds private func getSoundNameForAlarmKit(_ soundName: String) -> String { - // AlarmKit expects the sound name without extension + Design.debugLog("[alarmkit] Preparing sound for AlarmKit: \(soundName)") + + // Copy sound to Library/Sounds so AlarmKit can access it + if copySoundToLibrarySounds(soundName) { + Design.debugLog("[alarmkit] ✅ Sound ready in Library/Sounds: \(soundName)") + } else { + Design.debugLog("[alarmkit] ⚠️ Failed to copy sound to Library/Sounds, alarm may use default sound") + } + + // AlarmKit expects just the filename (with extension) when in Library/Sounds + return soundName + } + + /// Copy a sound file from AlarmSounds folder to Library/Sounds + /// Returns true if successful or file already exists + private func copySoundToLibrarySounds(_ soundName: String) -> Bool { + let fileManager = FileManager.default let nameWithoutExtension = (soundName as NSString).deletingPathExtension let ext = (soundName as NSString).pathExtension - Design.debugLog("[alarmkit] Sound name for AlarmKit: \(nameWithoutExtension) (from: \(soundName))") + // Try multiple locations for the source sound file + var sourceURL: URL? - // 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() + // 1. Try AlarmSounds subfolder in main bundle (Resources/AlarmSounds/) + if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext, subdirectory: "AlarmSounds") { + sourceURL = url + Design.debugLog("[alarmkit] Found sound in AlarmSounds subfolder: \(soundName)") + } + // 2. Try AlarmSounds.bundle + else if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"), + let alarmBundle = Bundle(url: bundleURL), + let url = alarmBundle.url(forResource: nameWithoutExtension, withExtension: ext) { + sourceURL = url + Design.debugLog("[alarmkit] Found sound in AlarmSounds.bundle: \(soundName)") + } + // 3. Try main bundle root + else if let url = Bundle.main.url(forResource: nameWithoutExtension, withExtension: ext) { + sourceURL = url + Design.debugLog("[alarmkit] Found sound in main bundle root: \(soundName)") } - return nameWithoutExtension + guard let sourceURL = sourceURL else { + Design.debugLog("[alarmkit] ❌ Sound file not found anywhere: \(soundName)") + logAvailableAlarmSounds() + return false + } + + // Get destination URL in Library/Sounds + guard let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first else { + Design.debugLog("[alarmkit] ❌ Could not get Library directory") + return false + } + + let soundsDirectory = libraryURL.appendingPathComponent("Sounds") + let destinationURL = soundsDirectory.appendingPathComponent(soundName) + + // Create Sounds directory if it doesn't exist + if !fileManager.fileExists(atPath: soundsDirectory.path) { + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + Design.debugLog("[alarmkit] Created Library/Sounds directory") + } catch { + Design.debugLog("[alarmkit] ❌ Failed to create Sounds directory: \(error)") + return false + } + } + + // Copy file if it doesn't exist or is different + if fileManager.fileExists(atPath: destinationURL.path) { + // Check if source is newer (simple check - could compare file sizes/hashes) + Design.debugLog("[alarmkit] Sound already exists in Library/Sounds: \(soundName)") + return true + } + + do { + try fileManager.copyItem(at: sourceURL, to: destinationURL) + Design.debugLog("[alarmkit] ✅ Copied sound to Library/Sounds: \(soundName)") + return true + } catch { + Design.debugLog("[alarmkit] ❌ Failed to copy sound: \(error)") + return false + } } - /// Log available sound files in the bundle for debugging - private func logBundleSoundFiles() { - Design.debugLog("[alarmkit] ========== BUNDLE SOUND FILES ==========") + /// Log available sound files in Library/Sounds for debugging + private func logLibrarySounds() { + guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else { + return + } - // Check main bundle + let soundsDirectory = libraryURL.appendingPathComponent("Sounds") + Design.debugLog("[alarmkit] ========== LIBRARY/SOUNDS FILES ==========") + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: soundsDirectory.path) + Design.debugLog("[alarmkit] Files in Library/Sounds: \(files)") + } catch { + Design.debugLog("[alarmkit] Library/Sounds directory doesn't exist or is empty") + } + } + + /// Log available alarm sounds in the bundle for debugging + private func logAvailableAlarmSounds() { + Design.debugLog("[alarmkit] ========== AVAILABLE ALARM SOUNDS ==========") + + // Check AlarmSounds subfolder if let resourcePath = Bundle.main.resourcePath { - let fileManager = FileManager.default + let alarmSoundsPath = (resourcePath as NSString).appendingPathComponent("AlarmSounds") + if FileManager.default.fileExists(atPath: alarmSoundsPath) { + do { + let files = try FileManager.default.contentsOfDirectory(atPath: alarmSoundsPath) + Design.debugLog("[alarmkit] Files in AlarmSounds folder: \(files)") + } catch { + Design.debugLog("[alarmkit] Error reading AlarmSounds folder: \(error)") + } + } else { + Design.debugLog("[alarmkit] AlarmSounds folder doesn't exist") + } + } + + // Check AlarmSounds.bundle + if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"), + let alarmBundle = Bundle(url: bundleURL), + let bundlePath = alarmBundle.resourcePath { 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)") - } + let files = try FileManager.default.contentsOfDirectory(atPath: bundlePath) + Design.debugLog("[alarmkit] Files in AlarmSounds.bundle: \(files)") } catch { - Design.debugLog("[alarmkit] Error listing bundle: \(error)") + Design.debugLog("[alarmkit] Error reading AlarmSounds.bundle: \(error)") } } } diff --git a/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json b/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json index ffe4424..7713261 100644 --- a/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json +++ b/TheNoiseClock/Resources/AlarmSounds.bundle/sounds.json @@ -6,7 +6,7 @@ "fileName": "digital-alarm.mp3", "description": "Classic digital alarm sound", "category": "alarm", - "bundleName": null, + "bundleName": "AlarmSounds", "isDefault": true }, { @@ -15,7 +15,7 @@ "fileName": "buzzing-alarm.mp3", "description": "Buzzing sound for gentle wake-up", "category": "alarm", - "bundleName": null + "bundleName": "AlarmSounds" }, { "id": "classic-alarm", @@ -23,7 +23,7 @@ "fileName": "classic-alarm.mp3", "description": "Traditional alarm sound", "category": "alarm", - "bundleName": null + "bundleName": "AlarmSounds" }, { "id": "beep-alarm", @@ -31,7 +31,7 @@ "fileName": "beep-alarm.mp3", "description": "Short beep alarm sound", "category": "alarm", - "bundleName": null + "bundleName": "AlarmSounds" }, { "id": "siren-alarm", @@ -39,7 +39,7 @@ "fileName": "siren-alarm.mp3", "description": "Emergency siren alarm for heavy sleepers", "category": "alarm", - "bundleName": null + "bundleName": "AlarmSounds" } ] } diff --git a/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 index 83dc7ae..f8edbce 100644 Binary files a/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 and b/TheNoiseClock/Resources/AlarmSounds/beep-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 index c36aa69..81e1efc 100644 Binary files a/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 and b/TheNoiseClock/Resources/AlarmSounds/buzzing-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 index dc61b0e..ef9d07e 100644 Binary files a/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 and b/TheNoiseClock/Resources/AlarmSounds/classic-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 index 57ffd9e..43fafe1 100644 Binary files a/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 and b/TheNoiseClock/Resources/AlarmSounds/digital-alarm.mp3 differ diff --git a/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 b/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 index 2553762..82508c2 100644 Binary files a/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 and b/TheNoiseClock/Resources/AlarmSounds/siren-alarm.mp3 differ diff --git a/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift b/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift index 9a6e9cd..600f4f5 100644 --- a/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift +++ b/TheNoiseClock/Shared/LiveActivity/NoiseClockAlarmMetadata.swift @@ -23,6 +23,9 @@ nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata { /// The alarm label to display var label: String + /// The custom notification message + var message: String + /// Volume level (0.0 to 1.0) var volume: Float } diff --git a/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift b/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift index 1c0681e..49dc5cf 100644 --- a/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift +++ b/TheNoiseClockWidget/NoiseClockAlarmMetadata.swift @@ -27,6 +27,9 @@ nonisolated struct NoiseClockAlarmMetadata: AlarmMetadata { /// The alarm label to display var label: String + /// The custom notification message + var message: String + /// Volume level (0.0 to 1.0) var volume: Float }