Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-02 14:12:45 -06:00
parent b8428ca134
commit 3844e19b39
11 changed files with 168 additions and 61 deletions

View File

@ -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 {

12
PRD.md
View File

@ -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

View File

@ -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<NoiseClockAlarmMetadata>(
@ -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)")
}
}
}

View File

@ -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"
}
]
}

View File

@ -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
}

View File

@ -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
}