diff --git a/PRD.md b/PRD.md index ca7ef44..dc2b829 100644 --- a/PRD.md +++ b/PRD.md @@ -484,8 +484,7 @@ TheNoiseClock/ │ │ └── OnboardingPageView.swift │ └── Resources/ │ ├── LaunchScreen.storyboard # Branded native launch screen -│ ├── sounds.json # Ambient sound configuration and definitions -│ ├── alarm-sounds.json # Alarm sound configuration and definitions +│ ├── SoundsSettings.json # Shared audio settings │ ├── Ambient.bundle/ # Ambient sound category │ │ └── white-noise.mp3 │ ├── Nature.bundle/ # Nature sound category @@ -493,11 +492,12 @@ TheNoiseClock/ │ ├── Mechanical.bundle/ # Mechanical sound category │ │ └── fan-white-noise-heater.mp3 │ ├── AlarmSounds.bundle/ # Alarm sound category -│ │ ├── digital-alarm.caf -│ │ ├── classic-alarm.caf -│ │ ├── beep-alarm.caf -│ │ ├── siren-alarm.caf -│ │ └── buzzing-alarm.caf +│ │ ├── sounds.json # Alarm sound metadata +│ │ ├── digital-alarm.mp3 +│ │ ├── classic-alarm.mp3 +│ │ ├── beep-alarm.mp3 +│ │ ├── siren-alarm.mp3 +│ │ └── buzzing-alarm.mp3 │ └── Assets.xcassets/ │ └── [Asset catalogs] └── TheNoiseClock.xcodeproj/ # Xcode project with AudioPlaybackKit dependency @@ -727,8 +727,8 @@ The following terminal commands are used for building and testing the project. T # Navigate to project directory cd /Users/mattbruce/Documents/Projects/TheNoiseClock -# Build for iOS Simulator (iPad mini) -xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build +# Build for iOS Simulator (iPhone 17 Pro Max) +xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build # Build for iOS Simulator (any device) xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build @@ -740,7 +740,7 @@ xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock build #### Error Checking Commands ```bash # Check for build errors only (filtered output) -xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10 +xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10 # Quick syntax check for specific files swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -749,15 +749,7 @@ swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift ``` #### Available Simulators -The following simulators are available for testing: -- **iPad mini (A17 Pro)** - Primary testing device -- **iPad (10th generation)** -- **iPad Air 11-inch (M2)** -- **iPad Air 13-inch (M2)** -- **iPad Pro 11-inch (M4)** -- **iPad Pro 13-inch (M4)** -- **iPhone 16, 16 Plus, 16 Pro, 16 Pro Max** -- **iPhone SE (3rd generation)** +Use **iPhone 17 Pro Max (iOS 26.2)** as the primary simulator for build and test validation. #### Build Troubleshooting 1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds @@ -772,6 +764,13 @@ The following simulators are available for testing: 4. **Test on simulator** using Xcode or terminal build 5. **Update PRD** if architectural changes are made +#### Recent Engineering Quality Updates +- Alarm creation now defaults to `digital-alarm.mp3` (matches bundled assets and AlarmKit expectations). +- `AlarmSoundService` now caches decoded configuration/settings and falls back gracefully if bundled JSON is missing or malformed. +- Alarm-change notifications are now separated from clock-style notifications using `Notification.Name.alarmsDidUpdate`. +- Date overlay formatting now uses a per-thread `DateFormatter` cache to reduce formatter churn. +- Shared `TheNoiseClock` scheme now includes both unit and UI test targets for consistent `xcodebuild test` execution. + ## Development Notes ### Project Information diff --git a/README.md b/README.md index 2543c38..d0ead27 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,13 @@ Open `TheNoiseClock.xcodeproj` and run the `TheNoiseClock` scheme. ### Terminal Build ```bash cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock -xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build +xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' build +``` + +### Terminal Test +```bash +cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock +xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' test ``` --- @@ -125,6 +131,16 @@ Swift access is provided via: --- +## Recent Engineering Updates + +- Alarm defaults now use `digital-alarm.mp3` for consistent AlarmKit scheduling. +- Alarm sound metadata loading now caches decoded data and falls back safely if configuration files are missing/corrupt. +- Alarm UI refresh uses a dedicated `alarmsDidUpdate` notification instead of reusing clock-style notifications. +- Date overlay formatting now uses cached formatters to reduce repeated allocation overhead. +- Shared scheme configuration includes both unit and UI tests under `xcodebuild ... -scheme TheNoiseClock test`. + +--- + ## Architecture TheNoiseClock follows a clean, modular structure: diff --git a/TheNoiseClock.xcodeproj/project.pbxproj b/TheNoiseClock.xcodeproj/project.pbxproj index 8954653..736f286 100644 --- a/TheNoiseClock.xcodeproj/project.pbxproj +++ b/TheNoiseClock.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -585,7 +585,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -607,9 +607,9 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock"; }; name = Debug; }; @@ -630,9 +630,9 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TheNoiseClock.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TheNoiseClock"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/The Noise Clock.app/The Noise Clock"; }; name = Release; }; @@ -651,7 +651,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = TheNoiseClock; }; @@ -672,7 +672,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = TheNoiseClock; }; @@ -692,7 +692,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -711,7 +711,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift b/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift index 8311067..702bcd3 100644 --- a/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift +++ b/TheNoiseClock/Features/Alarms/Intents/AlarmIntents.swift @@ -17,8 +17,8 @@ import Foundation /// 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") + static let title: LocalizedStringResource = "Stop Alarm" + static let description = IntentDescription("Stops the currently ringing alarm") @Parameter(title: "Alarm ID") var alarmId: String @@ -48,8 +48,8 @@ struct StopAlarmIntent: LiveActivityIntent { /// 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") + static let title: LocalizedStringResource = "Snooze Alarm" + static let description = IntentDescription("Snoozes the currently ringing alarm") @Parameter(title: "Alarm ID") var alarmId: String @@ -80,9 +80,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent { /// Intent to open the app when the user taps the Live Activity. struct OpenAlarmAppIntent: LiveActivityIntent { - static var title: LocalizedStringResource = "Open TheNoiseClock" - static var description = IntentDescription("Opens the app to the alarm screen") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open TheNoiseClock" + static let description = IntentDescription("Opens the app to the alarm screen") + static let openAppWhenRun = true @Parameter(title: "Alarm ID") var alarmId: String diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift index 684083f..976d71b 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmKitService.swift @@ -19,29 +19,28 @@ 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)") + Design.debugLog("[alarmkit] Authorization state: \(AlarmManager.shared.authorizationState)") } // MARK: - Authorization /// The current authorization state for AlarmKit var authorizationState: AlarmManager.AuthorizationState { - manager.authorizationState + AlarmManager.shared.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)") + Design.debugLog("[alarmkit] Requesting authorization, current state: \(AlarmManager.shared.authorizationState)") - switch manager.authorizationState { + switch AlarmManager.shared.authorizationState { case .notDetermined: do { - let state = try await manager.requestAuthorization() + let state = try await AlarmManager.shared.requestAuthorization() Design.debugLog("[alarmkit] Authorization result: \(state)") return state == .authorized } catch { @@ -73,7 +72,7 @@ final class AlarmKitService { Design.debugLog("[alarmkit] ID: \(alarm.id)") // Ensure we're authorized - if manager.authorizationState != .authorized { + if AlarmManager.shared.authorizationState != .authorized { Design.debugLog("[alarmkit] Not authorized, requesting...") let authorized = await requestAuthorization() guard authorized else { @@ -160,7 +159,7 @@ final class AlarmKitService { // Schedule the alarm do { - let scheduledAlarm = try await manager.schedule( + let scheduledAlarm = try await AlarmManager.shared.schedule( id: alarm.id, configuration: configuration ) @@ -299,7 +298,7 @@ final class AlarmKitService { func cancelAlarm(id: UUID) { Design.debugLog("[alarmkit] Cancelling alarm: \(id)") do { - try manager.cancel(id: id) + try AlarmManager.shared.cancel(id: id) Design.debugLog("[alarmkit] ✅ Alarm cancelled: \(id)") } catch { Design.debugLog("[alarmkit] ❌ Cancel error: \(error)") @@ -311,7 +310,7 @@ final class AlarmKitService { func stopAlarm(id: UUID) { Design.debugLog("[alarmkit] Stopping alarm: \(id)") do { - try manager.stop(id: id) + try AlarmManager.shared.stop(id: id) Design.debugLog("[alarmkit] ✅ Alarm stopped: \(id)") } catch { Design.debugLog("[alarmkit] ❌ Stop error: \(error)") @@ -323,7 +322,7 @@ final class AlarmKitService { func snoozeAlarm(id: UUID) { Design.debugLog("[alarmkit] Snoozing alarm: \(id)") do { - try manager.countdown(id: id) + try AlarmManager.shared.countdown(id: id) Design.debugLog("[alarmkit] ✅ Alarm snoozed: \(id)") } catch { Design.debugLog("[alarmkit] ❌ Snooze error: \(error)") @@ -334,14 +333,14 @@ final class AlarmKitService { /// Async sequence that emits the current set of alarms whenever changes occur. var alarmUpdates: some AsyncSequence<[AlarmKit.Alarm], Never> { - manager.alarmUpdates + AlarmManager.shared.alarmUpdates } /// Log current state of all scheduled alarms func logCurrentAlarms() { Design.debugLog("[alarmkit] ========== CURRENT ALARMS ==========") Task { - for await alarms in manager.alarmUpdates { + for await alarms in AlarmManager.shared.alarmUpdates { Design.debugLog("[alarmkit] Found \(alarms.count) alarm(s) in AlarmKit") for alarm in alarms { Design.debugLog("[alarmkit] - ID: \(alarm.id)") diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift index 626080f..fe3d410 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift @@ -40,7 +40,7 @@ final class AlarmService { alarms.append(alarm) updateAlarmLookup() saveAlarms() - NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) + NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil) } /// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService. @@ -53,7 +53,7 @@ final class AlarmService { alarms[index] = alarm updateAlarmLookup() saveAlarms() - NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) + NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil) } /// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService. @@ -62,7 +62,7 @@ final class AlarmService { alarms.removeAll { $0.id == id } updateAlarmLookup() saveAlarms() - NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) + NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil) } /// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService. @@ -71,7 +71,7 @@ final class AlarmService { alarms[index].isEnabled.toggle() Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)") saveAlarms() - NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) + NotificationCenter.default.post(name: .alarmsDidUpdate, object: nil) } func getAlarm(id: UUID) -> Alarm? { diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift index 93e696b..c99f882 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift @@ -10,41 +10,64 @@ import AudioPlaybackKit import Bedrock /// Extension service for alarm-specific sound functionality -class AlarmSoundService { +final class AlarmSoundService { static let shared = AlarmSoundService() // MARK: - Constants /// The category ID for alarm sounds as defined in alarm-sounds.json static let alarmCategoryId = "alarm" + + private let lock = NSLock() + private var cachedConfiguration: SoundConfiguration? + private var cachedSettings: AudioSettings? private init() {} /// Load alarm sound configuration from AlarmSounds.bundle private func loadAlarmConfiguration() -> SoundConfiguration { - guard let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"), - let alarmBundle = Bundle(url: bundleURL), - let url = alarmBundle.url(forResource: "sounds", withExtension: "json") else { - fatalError("❌ sounds.json not found in AlarmSounds.bundle. Ensure the bundle and file exist.") + lock.lock() + if let cachedConfiguration { + lock.unlock() + return cachedConfiguration } - - do { - let data = try Data(contentsOf: url) - let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data) - - // Load settings from separate SoundsSettings.json file - let settings = loadAudioSettings() - - return SoundConfiguration(sounds: soundsOnly.sounds, settings: settings) - } catch { - fatalError("❌ Error loading alarm sound configuration: \(error)") + lock.unlock() + + let configuration: SoundConfiguration + if let bundleURL = Bundle.main.url(forResource: "AlarmSounds", withExtension: "bundle"), + let alarmBundle = Bundle(url: bundleURL), + let url = alarmBundle.url(forResource: "sounds", withExtension: "json") { + do { + let data = try Data(contentsOf: url) + let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data) + configuration = SoundConfiguration(sounds: soundsOnly.sounds, settings: loadAudioSettings()) + } catch { + Design.debugLog("[audio] Warning: Failed to decode alarm sound config, using fallback sounds: \(error)") + configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings()) + } + } else { + Design.debugLog("[audio] Warning: AlarmSounds.bundle/sounds.json missing, using fallback sounds") + configuration = SoundConfiguration(sounds: fallbackAlarmSounds(), settings: loadAudioSettings()) } + + lock.lock() + cachedConfiguration = configuration + lock.unlock() + return configuration } /// Load audio settings from SoundsSettings.json private func loadAudioSettings() -> AudioSettings { + lock.lock() + if let cachedSettings { + lock.unlock() + return cachedSettings + } + lock.unlock() + + let settings: AudioSettings guard let url = Bundle.main.url(forResource: "SoundsSettings", withExtension: "json") else { Design.debugLog("[general] Warning: SoundsSettings.json not found, using default alarm settings") - return AudioSettings( + settings = AudioSettings( defaultVolume: 1.0, defaultLoopCount: -1, preloadSounds: true, @@ -53,16 +76,19 @@ class AlarmSoundService { audioSessionMode: "default", audioSessionOptions: ["mixWithOthers"] ) + lock.lock() + cachedSettings = settings + lock.unlock() + return settings } do { let data = try Data(contentsOf: url) - let settings = try JSONDecoder().decode(AudioSettings.self, from: data) + settings = try JSONDecoder().decode(AudioSettings.self, from: data) //Design.debugLog("[settings] Loaded audio settings for alarms from SoundsSettings.json") - return settings } catch { Design.debugLog("[general] Warning: Error loading audio settings for alarms, using defaults: \(error)") - return AudioSettings( + settings = AudioSettings( defaultVolume: 1.0, defaultLoopCount: -1, preloadSounds: true, @@ -72,6 +98,57 @@ class AlarmSoundService { audioSessionOptions: ["mixWithOthers"] ) } + + lock.lock() + cachedSettings = settings + lock.unlock() + return settings + } + + private func fallbackAlarmSounds() -> [Sound] { + [ + Sound( + id: "digital-alarm", + name: "Digital Alarm", + fileName: "digital-alarm.mp3", + category: Self.alarmCategoryId, + description: "Classic digital alarm sound", + bundleName: "AlarmSounds", + isDefault: true + ), + Sound( + id: "buzzing-alarm", + name: "Buzzing Alarm", + fileName: "buzzing-alarm.mp3", + category: Self.alarmCategoryId, + description: "Buzzing sound for gentle wake-up", + bundleName: "AlarmSounds" + ), + Sound( + id: "classic-alarm", + name: "Classic Alarm", + fileName: "classic-alarm.mp3", + category: Self.alarmCategoryId, + description: "Traditional alarm sound", + bundleName: "AlarmSounds" + ), + Sound( + id: "beep-alarm", + name: "Beep Alarm", + fileName: "beep-alarm.mp3", + category: Self.alarmCategoryId, + description: "Short beep alarm sound", + bundleName: "AlarmSounds" + ), + Sound( + id: "siren-alarm", + name: "Siren Alarm", + fileName: "siren-alarm.mp3", + category: Self.alarmCategoryId, + description: "Emergency siren alarm for heavy sleepers", + bundleName: "AlarmSounds" + ) + ] } /// Get all available alarm sounds diff --git a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift index b97a49c..b40a92d 100644 --- a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift @@ -17,7 +17,7 @@ struct AddAlarmView: View { @Binding var isPresented: Bool @State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date() - @State private var selectedSoundName = "digital-alarm.caf" + @State private var selectedSoundName = AppConstants.SystemSounds.defaultSound @State private var alarmLabel = "Alarm" @State private var notificationMessage = "Your alarm is ringing" @State private var snoozeDuration = 9 // minutes diff --git a/TheNoiseClock/Features/Clock/Services/BatteryService.swift b/TheNoiseClock/Features/Clock/Services/BatteryService.swift index 6ee2794..f29d2b5 100644 --- a/TheNoiseClock/Features/Clock/Services/BatteryService.swift +++ b/TheNoiseClock/Features/Clock/Services/BatteryService.swift @@ -19,7 +19,8 @@ final class BatteryService { var batteryLevel: Int = 100 var isCharging: Bool = false - @ObservationIgnored private var monitoringTask: Task? + @ObservationIgnored private var levelNotificationTask: Task? + @ObservationIgnored private var stateNotificationTask: Task? // MARK: - Initialization private init() { @@ -42,21 +43,20 @@ final class BatteryService { // MARK: - Private Methods private func startNotificationMonitoring() { - monitoringTask = Task { [weak self] in - await withTaskGroup(of: Void.self) { group in - // Monitor battery level changes - group.addTask { @MainActor [weak self] in - for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) { - self?.updateBatteryInfo() - } - } - - // Monitor battery state changes - group.addTask { @MainActor [weak self] in - for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) { - self?.updateBatteryInfo() - } - } + levelNotificationTask?.cancel() + stateNotificationTask?.cancel() + + levelNotificationTask = Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryLevelDidChangeNotification) { + guard !Task.isCancelled else { break } + await self?.updateBatteryInfo() + } + } + + stateNotificationTask = Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: UIDevice.batteryStateDidChangeNotification) { + guard !Task.isCancelled else { break } + await self?.updateBatteryInfo() } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift b/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift index 6edd991..a2939c4 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift @@ -32,8 +32,9 @@ struct TopOverlayView: View { let _ = clockUpdateTrigger // Force re-render on style or alarm changes let _ = alarmService.alarms // Observe all alarms for changes let _ = soundPlayer.isPlaying // Observe player state - - let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)") + let nextEnabledAlarm = alarmService.enabledAlarms + .sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }) + .first VStack(spacing: Design.Spacing.small) { // Row 1: Date and Battery (Standard Status Bar positions) @@ -57,7 +58,7 @@ struct TopOverlayView: View { // Row 2: Alarms and Noise Controls (Below Dynamic Island) HStack(alignment: .center) { - if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first { + if showNextAlarm, let nextAlarm = nextEnabledAlarm { NextAlarmOverlay( alarmTime: nextAlarm.time.nextOccurrence(), color: color, @@ -88,8 +89,8 @@ struct TopOverlayView: View { .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.small) .transition(.opacity) - .id(clockUpdateTrigger) // Force re-render on style or alarm changes - .onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in + .id(clockUpdateTrigger) // Force re-render on alarm changes + .onReceive(NotificationCenter.default.publisher(for: .alarmsDidUpdate)) { _ in clockUpdateTrigger.toggle() } .onAppear { diff --git a/TheNoiseClock/Shared/Design/NoiseClockTheme.swift b/TheNoiseClock/Shared/Design/NoiseClockTheme.swift index c30d5c5..e76c6bd 100644 --- a/TheNoiseClock/Shared/Design/NoiseClockTheme.swift +++ b/TheNoiseClock/Shared/Design/NoiseClockTheme.swift @@ -10,7 +10,7 @@ import Bedrock // MARK: - NoiseClock Surface Colors -public enum NoiseClockSurfaceColors: SurfaceColorProvider { +public enum NoiseClockSurfaceColors: @MainActor SurfaceColorProvider { public static let primary = Color(red: 0.06, green: 0.08, blue: 0.12) public static let secondary = Color(red: 0.09, green: 0.11, blue: 0.18) public static let tertiary = Color(red: 0.12, green: 0.15, blue: 0.22) @@ -22,7 +22,7 @@ public enum NoiseClockSurfaceColors: SurfaceColorProvider { // MARK: - NoiseClock Text Colors -public enum NoiseClockTextColors: TextColorProvider { +public enum NoiseClockTextColors: @MainActor TextColorProvider { public static let primary = Color.white public static let secondary = Color.white.opacity(Design.Opacity.accent) public static let tertiary = Color.white.opacity(Design.Opacity.medium) @@ -33,7 +33,7 @@ public enum NoiseClockTextColors: TextColorProvider { // MARK: - NoiseClock Accent Colors -public enum NoiseClockAccentColors: AccentColorProvider { +public enum NoiseClockAccentColors: @MainActor AccentColorProvider { public static let primary = Color(red: 0.45, green: 0.75, blue: 1.00) public static let light = Color(red: 0.65, green: 0.85, blue: 1.00) public static let dark = Color(red: 0.25, green: 0.55, blue: 0.90) @@ -42,7 +42,7 @@ public enum NoiseClockAccentColors: AccentColorProvider { // MARK: - NoiseClock Button Colors -public enum NoiseClockButtonColors: ButtonColorProvider { +public enum NoiseClockButtonColors: @MainActor ButtonColorProvider { public static let primaryLight = Color(red: 0.55, green: 0.80, blue: 1.00) public static let primaryDark = Color(red: 0.20, green: 0.50, blue: 0.85) public static let secondary = Color.white.opacity(Design.Opacity.subtle) @@ -52,7 +52,7 @@ public enum NoiseClockButtonColors: ButtonColorProvider { // MARK: - NoiseClock Status Colors -public enum NoiseClockStatusColors: StatusColorProvider { +public enum NoiseClockStatusColors: @MainActor StatusColorProvider { public static let success = Color(red: 0.20, green: 0.80, blue: 0.45) public static let warning = Color(red: 1.00, green: 0.75, blue: 0.20) public static let error = Color(red: 0.90, green: 0.30, blue: 0.30) @@ -61,7 +61,7 @@ public enum NoiseClockStatusColors: StatusColorProvider { // MARK: - NoiseClock Border Colors -public enum NoiseClockBorderColors: BorderColorProvider { +public enum NoiseClockBorderColors: @MainActor BorderColorProvider { public static let subtle = Color.white.opacity(Design.Opacity.subtle) public static let standard = Color.white.opacity(Design.Opacity.hint) public static let emphasized = Color.white.opacity(Design.Opacity.light) @@ -70,7 +70,7 @@ public enum NoiseClockBorderColors: BorderColorProvider { // MARK: - NoiseClock Interactive Colors -public enum NoiseClockInteractiveColors: InteractiveColorProvider { +public enum NoiseClockInteractiveColors: @MainActor InteractiveColorProvider { public static let selected = NoiseClockAccentColors.primary.opacity(Design.Opacity.selection) public static let hover = Color.white.opacity(Design.Opacity.subtle) public static let pressed = Color.white.opacity(Design.Opacity.hint) @@ -79,7 +79,7 @@ public enum NoiseClockInteractiveColors: InteractiveColorProvider { // MARK: - NoiseClock Theme -public enum NoiseClockTheme: AppColorTheme { +public enum NoiseClockTheme: @MainActor AppColorTheme { public typealias Surface = NoiseClockSurfaceColors public typealias Text = NoiseClockTextColors public typealias Accent = NoiseClockAccentColors diff --git a/TheNoiseClock/Shared/Extensions/Date+Extensions.swift b/TheNoiseClock/Shared/Extensions/Date+Extensions.swift index d247dc5..ece9531 100644 --- a/TheNoiseClock/Shared/Extensions/Date+Extensions.swift +++ b/TheNoiseClock/Shared/Extensions/Date+Extensions.swift @@ -8,14 +8,29 @@ import Foundation extension Date { + private static let formatterCacheKeyPrefix = "TheNoiseClock.DateFormatter." /// Format date for display in overlay with custom format /// - Parameter format: Date format string (e.g., "d MMM yyyy") /// - Returns: Formatted date string func formattedForOverlay(format: String = "d MMM yyyy") -> String { + let formatter = Self.cachedFormatter(for: format) + return formatter.string(from: self) + } + + private static func cachedFormatter(for format: String) -> DateFormatter { + let cacheKey = formatterCacheKeyPrefix + format + let threadDictionary = Thread.current.threadDictionary + if let cached = threadDictionary[cacheKey] as? DateFormatter { + return cached + } + let formatter = DateFormatter() formatter.dateFormat = format - return formatter.string(from: self) + formatter.locale = .autoupdatingCurrent + formatter.calendar = .autoupdatingCurrent + threadDictionary[cacheKey] = formatter + return formatter } /// Get available date format options with their display names diff --git a/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift b/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift index 253803c..7885c21 100644 --- a/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift +++ b/TheNoiseClock/Shared/Utilities/AlarmNotifications.swift @@ -10,4 +10,5 @@ import Foundation extension Notification.Name { static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested") static let clockStyleDidUpdate = Notification.Name("clockStyleDidUpdate") + static let alarmsDidUpdate = Notification.Name("alarmsDidUpdate") } diff --git a/TheNoiseClockTests/TheNoiseClockTests.swift b/TheNoiseClockTests/TheNoiseClockTests.swift index 49ab9f5..84d62b9 100644 --- a/TheNoiseClockTests/TheNoiseClockTests.swift +++ b/TheNoiseClockTests/TheNoiseClockTests.swift @@ -6,12 +6,43 @@ // import Testing -@testable import TheNoiseClock +@testable import The_Noise_Clock +import Foundation +@MainActor struct TheNoiseClockTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + @Test + func defaultAlarmSoundIsMP3() { + #expect(AppConstants.SystemSounds.defaultSound.hasSuffix(".mp3")) + } + + @Test + func nextOccurrenceIsInFuture() { + let oneMinuteAgo = Calendar.current.date(byAdding: .minute, value: -1, to: Date()) ?? Date() + let next = oneMinuteAgo.nextOccurrence() + + #expect(next > Date()) + #expect(next.timeIntervalSinceNow < 26 * 60 * 60) + } + + @Test + func clockStyleCodableRoundTripPreservesSettings() throws { + let style = ClockStyle() + style.showSeconds = true + style.showDate = false + style.keepAwake = true + style.fontFamily = .avenir + style.digitAnimationStyle = .glitch + + let data = try JSONEncoder().encode(style) + let decoded = try JSONDecoder().decode(ClockStyle.self, from: data) + + #expect(decoded.showSeconds) + #expect(decoded.showDate == false) + #expect(decoded.keepAwake) + #expect(decoded.fontFamily == .avenir) + #expect(decoded.digitAnimationStyle == .glitch) } } diff --git a/TheNoiseClockWidget/AlarmIntents.swift b/TheNoiseClockWidget/AlarmIntents.swift index a8a54f6..8f5f85d 100644 --- a/TheNoiseClockWidget/AlarmIntents.swift +++ b/TheNoiseClockWidget/AlarmIntents.swift @@ -18,8 +18,8 @@ import Foundation /// 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") + static let title: LocalizedStringResource = "Stop Alarm" + static let description = IntentDescription("Stops the currently ringing alarm") @Parameter(title: "Alarm ID") var alarmId: String @@ -49,8 +49,8 @@ struct StopAlarmIntent: LiveActivityIntent { /// 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") + static let title: LocalizedStringResource = "Snooze Alarm" + static let description = IntentDescription("Snoozes the currently ringing alarm") @Parameter(title: "Alarm ID") var alarmId: String @@ -81,9 +81,9 @@ struct SnoozeAlarmIntent: LiveActivityIntent { /// Intent to open the app when the user taps the Live Activity. struct OpenAlarmAppIntent: LiveActivityIntent { - static var title: LocalizedStringResource = "Open TheNoiseClock" - static var description = IntentDescription("Opens the app to the alarm screen") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open TheNoiseClock" + static let description = IntentDescription("Opens the app to the alarm screen") + static let openAppWhenRun = true @Parameter(title: "Alarm ID") var alarmId: String